d9dcac6195a4
feat: Wire conflict resolver into sync dispatcher (v2.13.25)
- time
- 2026-04-30 14:55 UTC · 6 months ago
- machine
- koh2
- parent
- 2563b3036de4
↓ peach-browser-d9dcac61.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+76 −0src/background/sync-coordinator.ts+48 −4manifest.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": {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",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": {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({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);