1 parent
c32d995112
commit
e3db66d901
7 files changed
+326
-89
No files matched your search
@@ -59,7 +59,7 @@ import { docToMarkdown, markdownToDoc, type Notebook, type MemoDetail, type Memo
|
||||
import { codeBlockLowlight } from "@/lib/code-block";
|
||||
import { compressImageForUpload } from "@/lib/image-compression";
|
||||
import { localDb, type MemoUpdateSyncPayload } from "@/lib/local-db";
|
||||
import { getMemoUpdateQueueId, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue";
|
||||
import { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue";
|
||||
import {
|
||||
getNotebookMoveOptions,
|
||||
DEFAULT_MEMO_TITLE,
|
||||
@@ -717,7 +717,7 @@ const MobileNativeEditorPane = ({
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const [draft, queuedUpdate, editSessionResponse] = memo.isDeleted
|
||||
let [draft, queuedUpdate, editSessionResponse] = memo.isDeleted
|
||||
? [null, null, null]
|
||||
: await Promise.all([
|
||||
localDb.drafts.get(memo.id),
|
||||
@@ -729,6 +729,15 @@ const MobileNativeEditorPane = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (queuedUpdate && isMemoUpdateAlreadyApplied(memo, queuedUpdate)) {
|
||||
await Promise.all([
|
||||
localDb.syncQueue.delete(queuedUpdate.id),
|
||||
localDb.drafts.delete(memo.id),
|
||||
]);
|
||||
draft = null;
|
||||
queuedUpdate = undefined;
|
||||
}
|
||||
|
||||
const draftUpdatedAt = draft ? Date.parse(draft.updatedAt) : 0;
|
||||
const remoteUpdatedAt = Date.parse(memo.updatedAt);
|
||||
const useDraft = Boolean(draft && (queuedUpdate || draftUpdatedAt >= remoteUpdatedAt));
|
||||
@@ -1640,7 +1649,7 @@ const RichEditorPane = ({
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const [draft, queuedUpdate, editSessionResponse] = memo.isDeleted
|
||||
let [draft, queuedUpdate, editSessionResponse] = memo.isDeleted
|
||||
? [null, null, null]
|
||||
: await Promise.all([
|
||||
localDb.drafts.get(memo.id),
|
||||
@@ -1652,6 +1661,15 @@ const RichEditorPane = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (queuedUpdate && isMemoUpdateAlreadyApplied(memo, queuedUpdate)) {
|
||||
await Promise.all([
|
||||
localDb.syncQueue.delete(queuedUpdate.id),
|
||||
localDb.drafts.delete(memo.id),
|
||||
]);
|
||||
draft = null;
|
||||
queuedUpdate = undefined;
|
||||
}
|
||||
|
||||
const draftUpdatedAt = draft ? Date.parse(draft.updatedAt) : 0;
|
||||
const remoteUpdatedAt = Date.parse(memo.updatedAt);
|
||||
const useDraft = Boolean(draft && (queuedUpdate || draftUpdatedAt >= remoteUpdatedAt));
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
type MobileEditorMemoResponse,
|
||||
type MobileEditorSaveState,
|
||||
} from "@/lib/mobile-editor-standalone";
|
||||
import { getMemoUpdateQueueId, queueMemoUpdate, shouldQueueMemoSaveError, syncQueuedChanges } from "@/lib/sync-queue";
|
||||
import { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue";
|
||||
|
||||
type ListNotebooksResponse = {
|
||||
notebooks: Notebook[];
|
||||
@@ -122,15 +122,6 @@ export const MobileStandaloneTiptapEditor = ({
|
||||
contentJson: draft.contentJson,
|
||||
updatedAt: draft.updatedAt,
|
||||
});
|
||||
void queueMemoUpdate({
|
||||
memoId: currentMemo.id,
|
||||
expectedRevision: currentMemo.revision,
|
||||
expectedContentHash: currentMemo.contentHash,
|
||||
editSessionId: editSessionRef.current?.id ?? "",
|
||||
title: draft.title,
|
||||
contentJson: draft.contentJson,
|
||||
tags: parseMobileEditorTags(draft.tagsText),
|
||||
});
|
||||
}, [draftKey]);
|
||||
|
||||
const readLocalDraft = useCallback((): MobileEditorDraft | null => {
|
||||
@@ -307,18 +298,20 @@ export const MobileStandaloneTiptapEditor = ({
|
||||
}
|
||||
|
||||
const path = `/api/v1/memos/${encodeURIComponent(currentMemo.id)}/save`;
|
||||
const body = JSON.stringify(buildSavePayload(currentMemo));
|
||||
const payload = buildSavePayload(currentMemo);
|
||||
const body = JSON.stringify(payload);
|
||||
const snapshot = currentSnapshot();
|
||||
|
||||
// A direct save supersedes an older retry for this memo. The local draft remains
|
||||
// durable until the server response is confirmed, so removing the retry cannot
|
||||
// lose the user's latest text even if the page is suspended immediately after.
|
||||
void localDb.syncQueue.delete(getMemoUpdateQueueId(currentMemo.id));
|
||||
|
||||
if (typeof navigator.sendBeacon === "function") {
|
||||
const accepted = navigator.sendBeacon(path, new Blob([body], { type: "application/json" }));
|
||||
|
||||
if (accepted) {
|
||||
backgroundSavePendingRef.current = true;
|
||||
void Promise.all([
|
||||
localDb.drafts.delete(currentMemo.id),
|
||||
localDb.syncQueue.delete(getMemoUpdateQueueId(currentMemo.id)),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -329,8 +322,11 @@ export const MobileStandaloneTiptapEditor = ({
|
||||
body,
|
||||
})
|
||||
.then((data) => applySavedMemo(data.memo, snapshot))
|
||||
.catch(() => {
|
||||
.catch((saveError) => {
|
||||
persistLocalDraft();
|
||||
if (shouldQueueMemoSaveError(saveError)) {
|
||||
void queueMemoUpdate({ memoId: currentMemo.id, ...payload });
|
||||
}
|
||||
setSaveStateStable("local-draft");
|
||||
});
|
||||
|
||||
@@ -397,22 +393,27 @@ export const MobileStandaloneTiptapEditor = ({
|
||||
setSaveStateStable("saving");
|
||||
setError(null);
|
||||
|
||||
currentSavePromiseRef.current = (async () => {
|
||||
const data = await requestMobileEditorJson<MobileEditorMemoResponse>(`/api/v1/memos/${encodeURIComponent(currentMemo.id)}`, {
|
||||
method: "PATCH",
|
||||
keepalive,
|
||||
body: JSON.stringify(buildSavePayload(currentMemo)),
|
||||
});
|
||||
|
||||
await applySavedMemo(data.memo, snapshot);
|
||||
return true;
|
||||
})();
|
||||
let payload: ReturnType<typeof buildSavePayload> | null = null;
|
||||
|
||||
try {
|
||||
payload = buildSavePayload(currentMemo);
|
||||
await localDb.syncQueue.delete(getMemoUpdateQueueId(currentMemo.id)).catch(() => undefined);
|
||||
currentSavePromiseRef.current = (async () => {
|
||||
const data = await requestMobileEditorJson<MobileEditorMemoResponse>(`/api/v1/memos/${encodeURIComponent(currentMemo.id)}`, {
|
||||
method: "PATCH",
|
||||
keepalive,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
await applySavedMemo(data.memo, snapshot);
|
||||
return true;
|
||||
})();
|
||||
|
||||
return await currentSavePromiseRef.current;
|
||||
} catch (saveError) {
|
||||
persistLocalDraft();
|
||||
if (shouldQueueMemoSaveError(saveError)) {
|
||||
if (payload && shouldQueueMemoSaveError(saveError)) {
|
||||
await queueMemoUpdate({ memoId: currentMemo.id, ...payload });
|
||||
setError(null);
|
||||
setSaveStateStable("local-draft");
|
||||
return true;
|
||||
@@ -643,8 +644,19 @@ export const MobileStandaloneTiptapEditor = ({
|
||||
const nextTitle = data.memo.title || "";
|
||||
const nextTagsText = Array.isArray(data.memo.tags) ? data.memo.tags.join(", ") : "";
|
||||
const nextContentJson = normalizeMobileEditorDoc(data.memo);
|
||||
const draft = await readBestLocalDraft();
|
||||
const queuedUpdate = await localDb.syncQueue.get(getMemoUpdateQueueId(data.memo.id));
|
||||
let draft = await readBestLocalDraft();
|
||||
let queuedUpdate = await localDb.syncQueue.get(getMemoUpdateQueueId(data.memo.id));
|
||||
if (queuedUpdate && isMemoUpdateAlreadyApplied(data.memo, queuedUpdate)) {
|
||||
await Promise.all([
|
||||
localDb.syncQueue.delete(queuedUpdate.id),
|
||||
localDb.drafts.delete(data.memo.id),
|
||||
]);
|
||||
if (draftKey) {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
draft = null;
|
||||
queuedUpdate = undefined;
|
||||
}
|
||||
const useDraft = Boolean(draft && (queuedUpdate || Date.parse(draft.updatedAt || "") >= Date.parse(data.memo.updatedAt || "")));
|
||||
|
||||
setMemo(data.memo);
|
||||
@@ -720,42 +732,23 @@ export const MobileStandaloneTiptapEditor = ({
|
||||
}
|
||||
|
||||
if (document.visibilityState === "visible") {
|
||||
void reconcileBackgroundSave();
|
||||
void syncQueuedChanges({
|
||||
onSynced: (syncedMemo) => {
|
||||
if (syncedMemo.id !== memoRef.current?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
memoRef.current = syncedMemo;
|
||||
setMemo(syncedMemo);
|
||||
lastSavedSnapshotRef.current = currentSnapshot();
|
||||
dirtyRef.current = false;
|
||||
if (draftKey) {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
setSaveStateStable("saved");
|
||||
},
|
||||
});
|
||||
void (async () => {
|
||||
await reconcileBackgroundSave();
|
||||
if (dirtyRef.current && !backgroundSavePendingRef.current) {
|
||||
await saveNowRef.current();
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
const handleOnline = () => {
|
||||
void syncQueuedChanges({
|
||||
onSynced: (syncedMemo) => {
|
||||
if (syncedMemo.id !== memoRef.current?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
memoRef.current = syncedMemo;
|
||||
setMemo(syncedMemo);
|
||||
lastSavedSnapshotRef.current = currentSnapshot();
|
||||
dirtyRef.current = false;
|
||||
if (draftKey) {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
setSaveStateStable("saved");
|
||||
},
|
||||
});
|
||||
void (async () => {
|
||||
if (backgroundSavePendingRef.current) {
|
||||
await reconcileBackgroundSave();
|
||||
}
|
||||
if (dirtyRef.current && !backgroundSavePendingRef.current) {
|
||||
await saveNowRef.current();
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
window.addEventListener("pagehide", handlePageHide);
|
||||
|
||||
@@ -30,6 +30,7 @@ export type SyncQueueItem = {
|
||||
attemptCount: number;
|
||||
lastError: string | null;
|
||||
nextAttemptAt: string | null;
|
||||
claimId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
+100
-24
@@ -31,19 +31,22 @@ export const getMemoUpdateQueueId = (memoId: string) => `memo.update:${memoId}`;
|
||||
export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload) => {
|
||||
const id = getMemoUpdateQueueId(payload.memoId);
|
||||
const now = new Date().toISOString();
|
||||
const existing = await localDb.syncQueue.get(id);
|
||||
await localDb.transaction("rw", localDb.syncQueue, async () => {
|
||||
const existing = await localDb.syncQueue.get(id);
|
||||
|
||||
await localDb.syncQueue.put({
|
||||
id,
|
||||
kind: "memo.update",
|
||||
memoId: payload.memoId,
|
||||
status: "pending",
|
||||
payload,
|
||||
attemptCount: existing?.attemptCount ?? 0,
|
||||
lastError: null,
|
||||
nextAttemptAt: null,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
await localDb.syncQueue.put({
|
||||
id,
|
||||
kind: "memo.update",
|
||||
memoId: payload.memoId,
|
||||
status: "pending",
|
||||
payload,
|
||||
attemptCount: existing?.attemptCount ?? 0,
|
||||
lastError: null,
|
||||
nextAttemptAt: null,
|
||||
claimId: null,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -56,9 +59,36 @@ export const observeSyncQueue = (onChange: (summary: SyncQueueSummary) => void)
|
||||
return () => subscription.unsubscribe();
|
||||
};
|
||||
|
||||
export const syncQueuedChanges = async (options: {
|
||||
export const isMemoUpdateAlreadyApplied = (memo: MemoDetail, item: SyncQueueItem) => {
|
||||
if (memo.id !== item.memoId || memo.title !== item.payload.title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const remoteTags = [...memo.tags].sort((left, right) => left.localeCompare(right));
|
||||
const queuedTags = [...item.payload.tags].sort((left, right) => left.localeCompare(right));
|
||||
return JSON.stringify(remoteTags) === JSON.stringify(queuedTags) &&
|
||||
JSON.stringify(memo.contentJson) === JSON.stringify(item.payload.contentJson);
|
||||
};
|
||||
|
||||
let activeSyncPromise: Promise<SyncRunResult> | null = null;
|
||||
|
||||
export const syncQueuedChanges = (options: {
|
||||
onSynced?: (memo: MemoDetail) => void | Promise<void>;
|
||||
} = {}): Promise<SyncRunResult> => {
|
||||
if (activeSyncPromise) {
|
||||
return activeSyncPromise;
|
||||
}
|
||||
|
||||
activeSyncPromise = runQueuedChanges(options).finally(() => {
|
||||
activeSyncPromise = null;
|
||||
});
|
||||
|
||||
return activeSyncPromise;
|
||||
};
|
||||
|
||||
const runQueuedChanges = async (options: {
|
||||
onSynced?: (memo: MemoDetail) => void | Promise<void>;
|
||||
}): Promise<SyncRunResult> => {
|
||||
const result: SyncRunResult = {
|
||||
attempted: 0,
|
||||
synced: 0,
|
||||
@@ -75,32 +105,38 @@ export const syncQueuedChanges = async (options: {
|
||||
.filter((item) => !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now)
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
|
||||
for (const item of items) {
|
||||
for (const candidate of items) {
|
||||
const item = await claimQueueItem(candidate.id);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.attempted += 1;
|
||||
await localDb.syncQueue.update(item.id, {
|
||||
status: "syncing",
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const memo = await syncQueueItem(item);
|
||||
await localDb.syncQueue.delete(item.id);
|
||||
await localDb.drafts.delete(item.memoId);
|
||||
await options.onSynced?.(memo);
|
||||
result.synced += 1;
|
||||
const removed = await removeClaimedQueueItem(item);
|
||||
if (removed) {
|
||||
await localDb.drafts.delete(item.memoId);
|
||||
await options.onSynced?.(memo);
|
||||
result.synced += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
const status = isRevisionConflict(error) ? "conflict" : "error";
|
||||
const attemptCount = item.attemptCount + 1;
|
||||
|
||||
await localDb.syncQueue.update(item.id, {
|
||||
const updated = await updateClaimedQueueItem(item, {
|
||||
status,
|
||||
attemptCount,
|
||||
lastError: getErrorMessage(error),
|
||||
nextAttemptAt: status === "error" ? nextRetryAt(attemptCount) : null,
|
||||
claimId: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (status === "conflict") {
|
||||
if (!updated) {
|
||||
continue;
|
||||
} else if (status === "conflict") {
|
||||
result.conflicted += 1;
|
||||
} else {
|
||||
result.failed += 1;
|
||||
@@ -111,6 +147,46 @@ export const syncQueuedChanges = async (options: {
|
||||
return result;
|
||||
};
|
||||
|
||||
const claimQueueItem = (id: string) =>
|
||||
localDb.transaction("rw", localDb.syncQueue, async () => {
|
||||
const item = await localDb.syncQueue.get(id);
|
||||
if (!item || (item.status !== "pending" && item.status !== "error")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const claimId = crypto.randomUUID();
|
||||
const claimedItem: SyncQueueItem = {
|
||||
...item,
|
||||
status: "syncing",
|
||||
claimId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await localDb.syncQueue.put(claimedItem);
|
||||
return claimedItem;
|
||||
});
|
||||
|
||||
const removeClaimedQueueItem = (item: SyncQueueItem) =>
|
||||
localDb.transaction("rw", localDb.syncQueue, async () => {
|
||||
const current = await localDb.syncQueue.get(item.id);
|
||||
if (!current || current.claimId !== item.claimId || current.status !== "syncing") {
|
||||
return false;
|
||||
}
|
||||
|
||||
await localDb.syncQueue.delete(item.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const updateClaimedQueueItem = (item: SyncQueueItem, patch: Partial<SyncQueueItem>) =>
|
||||
localDb.transaction("rw", localDb.syncQueue, async () => {
|
||||
const current = await localDb.syncQueue.get(item.id);
|
||||
if (!current || current.claimId !== item.claimId || current.status !== "syncing") {
|
||||
return false;
|
||||
}
|
||||
|
||||
await localDb.syncQueue.update(item.id, patch);
|
||||
return true;
|
||||
});
|
||||
|
||||
export const shouldQueueMemoSaveError = (error: unknown) => {
|
||||
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
||||
return true;
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"autoprefixer": "latest",
|
||||
"concurrently": "latest",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"postcss": "latest",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "latest",
|
||||
@@ -1606,6 +1607,8 @@
|
||||
|
||||
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
|
||||
|
||||
"fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"autoprefixer": "latest",
|
||||
"concurrently": "latest",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"postcss": "latest",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "latest",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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 { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, syncQueuedChanges } = await import("../apps/web/src/lib/sync-queue");
|
||||
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, async () => {
|
||||
await localDb.drafts.clear();
|
||||
await localDb.syncQueue.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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user