import { afterEach, describe, expect, test } from "bun:test"; import { IDBKeyRange, indexedDB } from "fake-indexeddb"; globalThis.indexedDB = indexedDB; globalThis.IDBKeyRange = IDBKeyRange; const { localDb } = await import("./local-db.ts"); const { api } = await import("./api.ts"); const { createLocalDataScope, createLocalMemo, getLocalMemo, putLocalMemo, replaceLocalMemoId, createLocalResource, listLocalResources } = await import("./local-mirror.ts"); const { discardWebConflicts, getMemoUpdateQueueId, queueLocalAction, queueMemoCreate, queueMemoUpdate, syncQueuedChanges } = await import("./sync-queue.ts"); afterEach(async () => { await localDb.transaction( "rw", [localDb.drafts, localDb.syncQueue, localDb.notebooks, localDb.memos, localDb.templates, localDb.revisions, localDb.resources, localDb.syncMeta, localDb.idMappings], async () => { await Promise.all([ localDb.drafts.clear(), localDb.syncQueue.clear(), localDb.notebooks.clear(), localDb.memos.clear(), localDb.templates.clear(), localDb.revisions.clear(), localDb.resources.clear(), localDb.syncMeta.clear(), localDb.idMappings.clear(), ]); }, ); }); describe("web sync conflict recovery", () => { test("preserves a newer draft written while a memo update is in flight", async () => { const previousOnline = globalThis.navigator?.onLine; if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true }); const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); const createdMemo = await createLocalMemo(scope, { notebookId: "inbox" }); const remoteMemo = { ...createdMemo, id: "memo-draft-in-flight", revision: 1, contentHash: "hash-1", }; const syncedContent = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "synced snapshot" }] }] }; const newerContent = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "still typing" }] }] }; await putLocalMemo(scope, remoteMemo); await queueMemoUpdate({ memoId: remoteMemo.id, expectedRevision: 1, expectedContentHash: "hash-1", editSessionId: "edit-a", title: "Synced snapshot", contentJson: syncedContent, tags: [], }, scope); let updateStarted; let releaseUpdate; const updateStartedPromise = new Promise((resolve) => { updateStarted = resolve; }); const releaseUpdatePromise = new Promise((resolve) => { releaseUpdate = resolve; }); const original = { createMemoEditSession: api.createMemoEditSession, updateMemo: api.updateMemo, }; api.createMemoEditSession = async () => ({ editSession: { id: "fresh-edit", baseRevision: 1, baseContentHash: "hash-1" } }); api.updateMemo = async (_memoId, payload) => { updateStarted(); await releaseUpdatePromise; return { memo: { ...remoteMemo, ...payload, revision: 2, contentHash: "hash-2" } }; }; try { const callbackOrder = []; const syncing = syncQueuedChanges({ scope, onMemoAcknowledged: () => callbackOrder.push("acknowledged"), onSynced: () => callbackOrder.push("synced"), }); await updateStartedPromise; await localDb.drafts.put({ memoId: remoteMemo.id, title: "Still typing", tagsText: "", contentJson: newerContent, updatedAt: "2099-01-01T00:00:00.000Z", }); releaseUpdate(); expect(await syncing).toMatchObject({ synced: 1, conflicted: 0 }); expect(callbackOrder).toEqual(["acknowledged", "synced"]); expect(await localDb.drafts.get(remoteMemo.id)).toMatchObject({ title: "Still typing", contentJson: newerContent, }); } finally { api.createMemoEditSession = original.createMemoEditSession; api.updateMemo = original.updateMemo; if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline }); } }); test("preserves a draft written while a new memo create request is in flight", async () => { const previousOnline = globalThis.navigator?.onLine; if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true }); const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); const localMemo = await createLocalMemo(scope, { notebookId: "inbox" }); await queueMemoCreate(scope, { temporaryId: localMemo.id, notebookId: localMemo.notebookId, title: "", contentMarkdown: "", tags: [], createdAt: localMemo.createdAt, updatedAt: localMemo.updatedAt, }); let createStarted; let releaseCreate; const createStartedPromise = new Promise((resolve) => { createStarted = resolve; }); const releaseCreatePromise = new Promise((resolve) => { releaseCreate = resolve; }); const remoteMemo = { ...localMemo, id: "memo-remote", revision: 1, contentHash: "remote-empty-hash", }; const originalCreateMemo = api.createMemo; api.createMemo = async () => { createStarted(); await releaseCreatePromise; return { memo: remoteMemo }; }; try { const syncing = syncQueuedChanges({ scope, onSynced: async (memo, item) => { if (item.kind === "memo.create") await replaceLocalMemoId(scope, item.memoId, memo); }, }); await createStartedPromise; await localDb.drafts.put({ memoId: localMemo.id, title: "Pasted while syncing", tagsText: "important", contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "must survive" }] }] }, updatedAt: "2099-01-01T00:00:00.000Z", }); releaseCreate(); expect(await syncing).toMatchObject({ synced: 1, failed: 0 }); expect(await localDb.drafts.get(localMemo.id)).toBeUndefined(); expect(await localDb.drafts.get(remoteMemo.id)).toMatchObject({ title: "Pasted while syncing" }); expect(await getLocalMemo(scope, remoteMemo.id)).toMatchObject({ title: "Pasted while syncing", contentText: "must survive", revision: remoteMemo.revision, contentHash: remoteMemo.contentHash, }); } finally { api.createMemo = originalCreateMemo; if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline }); } }); test("rebases an edit queued before a new memo receives its remote id", async () => { const previousOnline = globalThis.navigator?.onLine; if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true }); const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); const localMemo = await createLocalMemo(scope, { notebookId: "inbox" }); await queueMemoCreate(scope, { temporaryId: localMemo.id, notebookId: localMemo.notebookId, title: "", contentMarkdown: "", tags: [], createdAt: localMemo.createdAt, updatedAt: localMemo.updatedAt, }); await queueMemoUpdate({ memoId: localMemo.id, expectedRevision: 0, expectedContentHash: localMemo.contentHash, editSessionId: `local-edit:${localMemo.id}`, title: "Queued edit", contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "queued body" }] }] }, tags: [], }, scope); const remoteMemo = { ...localMemo, id: "memo-remote-rebased", revision: 3, contentHash: "remote-base-hash" }; const original = { createMemo: api.createMemo, createMemoEditSession: api.createMemoEditSession, updateMemo: api.updateMemo, }; let receivedUpdate = null; api.createMemo = async () => ({ memo: remoteMemo }); api.createMemoEditSession = async () => ({ editSession: { id: "fresh-remote-session", baseRevision: remoteMemo.revision, baseContentHash: remoteMemo.contentHash, }, }); api.updateMemo = async (_memoId, payload) => { receivedUpdate = payload; return { memo: { ...remoteMemo, ...payload, revision: remoteMemo.revision + 1, contentHash: "updated-hash" } }; }; try { await syncQueuedChanges({ scope }); const update = await localDb.syncQueue.get(getMemoUpdateQueueId(remoteMemo.id)); expect(update).toMatchObject({ memoId: remoteMemo.id, payload: { memoId: remoteMemo.id, expectedRevision: remoteMemo.revision, expectedContentHash: remoteMemo.contentHash, title: "Queued edit", }, }); expect(await localDb.syncQueue.get(getMemoUpdateQueueId(localMemo.id))).toBeUndefined(); expect(await syncQueuedChanges({ scope })).toMatchObject({ synced: 1, conflicted: 0 }); expect(receivedUpdate).toMatchObject({ expectedRevision: remoteMemo.revision, expectedContentHash: remoteMemo.contentHash, editSessionId: "fresh-remote-session", title: "Queued edit", }); expect(await localDb.syncQueue.get(getMemoUpdateQueueId(remoteMemo.id))).toBeUndefined(); } finally { api.createMemo = original.createMemo; api.createMemoEditSession = original.createMemoEditSession; api.updateMemo = original.updateMemo; if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline }); } }); test("restores the authoritative remote memo and removes the conflict", async () => { const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); const memo = await createLocalMemo(scope, { notebookId: "inbox", title: "Local draft" }); const remote = { ...memo, title: "Remote version", revision: 4 }; const originalGetMemo = api.getMemo; api.getMemo = async () => ({ memo: remote }); await localDb.syncQueue.put({ id: "memo.update:conflict", kind: "memo.update", scope, memoId: memo.id, status: "conflict", payload: { memoId: memo.id, expectedRevision: 0, expectedContentHash: memo.contentHash, editSessionId: "session", title: "Local draft", contentJson: memo.contentJson, tags: [] }, attemptCount: 1, lastError: "conflict", nextAttemptAt: null, claimId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); expect(await discardWebConflicts(scope)).toBe(1); expect((await getLocalMemo(scope, memo.id))?.title).toBe("Remote version"); expect(await localDb.syncQueue.get("memo.update:conflict")).toBeUndefined(); api.getMemo = originalGetMemo; }); test("uploads an offline resource and rewrites its memo reference", async () => { const previousCaches = globalThis.caches; const previousOnline = globalThis.navigator?.onLine; if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true }); const entries = new Map(); globalThis.caches = { open: async () => ({ put: async (key, response) => entries.set(key, response), match: async (key) => entries.get(key) ?? undefined, delete: async (key) => entries.delete(key), }), }; const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); const memo = await createLocalMemo(scope, { notebookId: "inbox", title: "With file" }); const staged = await createLocalResource(scope, memo.id, new File(["offline"], "offline.txt", { type: "text/plain" })); const localMemo = await getLocalMemo(scope, memo.id); const placeholderMemo = { ...localMemo, contentMarkdown: `[offline](${staged.url})`, contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: staged.url }] }] }, }; await putLocalMemo(scope, placeholderMemo); await queueLocalAction(scope, "resource.create", staged.id, { resourceId: staged.id, memoId: memo.id, filename: staged.filename, mimeType: staged.mimeType, url: staged.url, }, memo.id); const original = { uploadMemoResource: api.uploadMemoResource, getMemo: api.getMemo, createMemoEditSession: api.createMemoEditSession, updateMemo: api.updateMemo, }; const remoteResource = { ...staged, id: "resource-remote", url: "/api/v1/resources/resource-remote/blob" }; const remoteMemo = { ...placeholderMemo, contentMarkdown: `[offline](${staged.url})` }; api.uploadMemoResource = async () => ({ resource: remoteResource }); api.getMemo = async () => ({ memo: remoteMemo }); api.createMemoEditSession = async () => ({ editSession: { id: "edit-1", baseRevision: 1, baseContentHash: remoteMemo.contentHash } }); api.updateMemo = async (_memoId, payload) => ({ memo: { ...remoteMemo, ...payload, contentHash: "patched", revision: 2 } }); try { const result = await syncQueuedChanges({ scope }); expect(result.synced).toBe(1); expect((await getLocalMemo(scope, memo.id))?.contentMarkdown).toContain(remoteResource.url); expect((await listLocalResources(scope)).resources[0]?.id).toBe(remoteResource.id); expect(await localDb.syncQueue.count()).toBe(0); } finally { api.uploadMemoResource = original.uploadMemoResource; api.getMemo = original.getMemo; api.createMemoEditSession = original.createMemoEditSession; api.updateMemo = original.updateMemo; globalThis.caches = previousCaches; if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline }); } }); });