d9dcac6195a4

feat: Wire conflict resolver into sync dispatcher (v2.13.25)

- SYNC_MANIFEST: for each conflicting id, chooseConflictWinner runs against localHash vs remoteHash - Local wins → move into offerToRemote - Remote wins → move into requestFromRemote and queue a buildConflictedCopy archive - Archive copies persist via restoreEntries(entries, merge) before outbound request, so peer's winning entry can land at the original id - Outbound ids deduplicated and lex-sorted; merge totals propagate to runSyncSession result - Cover local-wins-offer and remote-wins-request+archive paths in two new tests (25 total) Verification: npm typecheck, focused coordinator 25/25, npm test 281/281, npm run build clean.

time
2026-04-30 14:55 UTC · 6 months ago
machine
koh2
parent
2563b3036de4
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+76 −0src/background/sync-coordinator.ts+48 −4
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.24",4+  "version": "2.13.25",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.24",4+  "version": "2.13.25",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.13.24",3+  "version": "2.13.25",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/sync-coordinator.test.ts+76 −0
@@ -218,6 +218,82 @@218218       }219219     });220220 221+    it("resolves equal-timestamp conflicts deterministically: local wins → offer", async () => {222+      const localEntries = [entry({ id: "shared", title: "LocalEdit", modifiedAt: 1_000 })];223+      const restoreEntries = vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 }));224+      const localManifest = (225+        await (await import("../shared/sync-protocol")).buildSyncManifest(localEntries)226+      )[0]!;227+      // Pick a remote hash that lex-compares larger so local wins.228+      const remoteHash =229+        localManifest.hash < "f".repeat(64) ? "f".repeat(64) : "0".repeat(64);230+231+      const result = await dispatchSyncFrame(232+        encodeSyncMessage({233+          type: "SYNC_MANIFEST",234+          entries: [{ id: "shared", modifiedAt: 1_000, hash: remoteHash }],235+        }),236+        new Uint8Array(32).fill(20),237+        {238+          isProUser: async () => true,239+          isUnlocked: () => true,240+          getEntries: () => localEntries,241+          restoreEntries,242+        }243+      );244+245+      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));246+      // Local wins → offer "shared", no request, no archive.247+      expect(restoreEntries).not.toHaveBeenCalled();248+      const offer = messages.find((m) => m.type === "SYNC_ENTRIES");249+      expect(offer?.type).toBe("SYNC_ENTRIES");250+      if (offer?.type === "SYNC_ENTRIES") {251+        expect(offer.entries.map((e) => e.id)).toEqual(["shared"]);252+      }253+      expect(messages.find((m) => m.type === "SYNC_REQUEST")).toBeUndefined();254+    });255+256+    it("resolves equal-timestamp conflicts deterministically: remote wins → request + archive", async () => {257+      const localEntries = [entry({ id: "shared", title: "LocalEdit", modifiedAt: 1_000 })];258+      const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));259+      const localManifest = (260+        await (await import("../shared/sync-protocol")).buildSyncManifest(localEntries)261+      )[0]!;262+      // Pick a remote hash that lex-compares smaller so remote wins.263+      const remoteHash =264+        localManifest.hash > "0".repeat(64) ? "0".repeat(64) : "f".repeat(64);265+266+      const result = await dispatchSyncFrame(267+        encodeSyncMessage({268+          type: "SYNC_MANIFEST",269+          entries: [{ id: "shared", modifiedAt: 1_000, hash: remoteHash }],270+        }),271+        new Uint8Array(32).fill(21),272+        {273+          isProUser: async () => true,274+          isUnlocked: () => true,275+          getEntries: () => localEntries,276+          restoreEntries,277+        }278+      );279+280+      // Remote wins → request "shared" from peer, archive our local copy under281+      // a derived id, no offer.282+      expect(restoreEntries).toHaveBeenCalledTimes(1);283+      const archived = (restoreEntries.mock.calls[0] as unknown as [VaultEntry[]])[0];284+      expect(archived).toHaveLength(1);285+      expect(archived[0]!.id).toBe(`shared-conflict-${localManifest.hash.slice(0, 8)}`);286+      expect(archived[0]!.title).toBe("LocalEdit (conflicted copy)");287+288+      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));289+      expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({290+        type: "SYNC_REQUEST",291+        ids: ["shared"],292+      });293+      expect(messages.find((m) => m.type === "SYNC_ENTRIES")).toBeUndefined();294+      expect(result.merge).toEqual({ added: 1, updated: 0, skipped: 0 });295+    });296+221297     it("returns SYNC_DONE when manifests already agree", async () => {222298       const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]);223299       const localManifest = encodeSyncMessage({

view file

src/background/sync-coordinator.ts+48 −4
@@ -8,7 +8,9 @@88  */99 1010 import {11+  buildConflictedCopy,1112   buildSyncManifest,13+  chooseConflictWinner,1214   diffSyncManifests,1315   selectEntriesForSync,1416 } from "../shared/sync-protocol";@@ -246,11 +248,53 @@246248       const localEntries = readyDeps.getEntries();247249       const localManifest = await buildSyncManifest(localEntries);248250       const diff = diffSyncManifests(localManifest, message.entries);249-      const offered = selectEntriesForSync(localEntries, diff.offerToRemote);251+252+      const requestIds = new Set(diff.requestFromRemote);253+      const offerIds = new Set(diff.offerToRemote);254+      const archiveCopies: VaultEntry[] = [];255+256+      if (diff.conflicts.length > 0) {257+        const localManifestById = new Map(258+          localManifest.map((entry) => [entry.id, entry])259+        );260+        const remoteManifestById = new Map(261+          message.entries.map((entry) => [entry.id, entry])262+        );263+        const localEntryById = new Map(localEntries.map((entry) => [entry.id, entry]));264+265+        for (const id of diff.conflicts) {266+          const localManifestEntry = localManifestById.get(id);267+          const remoteManifestEntry = remoteManifestById.get(id);268+          const localEntry = localEntryById.get(id);269+          if (!localManifestEntry || !remoteManifestEntry || !localEntry) continue;270+271+          const winner = chooseConflictWinner(272+            localManifestEntry.hash,273+            remoteManifestEntry.hash274+          );275+          if (winner === "local") {276+            offerIds.add(id);277+          } else {278+            requestIds.add(id);279+            archiveCopies.push(buildConflictedCopy(localEntry, localManifestEntry.hash));280+          }281+        }282+      }283+284+      // Persist any losing-side conflicted copies before the peer's winning285+      // entries overwrite the original ids on the next SYNC_ENTRIES message.286+      let merge: { added: number; updated: number; skipped: number } | undefined;287+      if (archiveCopies.length > 0) {288+        merge = await readyDeps.restoreEntries(archiveCopies, "merge");289+      }290+291+      const sortedRequestIds = [...requestIds].sort();292+      const sortedOfferIds = [...offerIds].sort();293+      const offered = selectEntriesForSync(localEntries, sortedOfferIds);250294       const outbound: string[] = [];251-      if (diff.requestFromRemote.length > 0) {295+      if (sortedRequestIds.length > 0) {252296         outbound.push(253-          encodeSyncMessage({ type: "SYNC_REQUEST", ids: diff.requestFromRemote })297+          encodeSyncMessage({ type: "SYNC_REQUEST", ids: sortedRequestIds })254298         );255299       }256300       if (offered.length > 0) {@@ -266,7 +310,7 @@266310       if (outbound.length === 0) {267311         outbound.push(encodeSyncMessage({ type: "SYNC_DONE" }));268312       }269-      return { outbound };313+      return merge ? { outbound, merge } : { outbound };270314     }271315     case "SYNC_REQUEST": {272316       const offered = selectEntriesForSync(readyDeps.getEntries(), message.ids);

view file