2563b3036de4
feat: Add deterministic sync conflict resolver (v2.13.24)
- time
- 2026-04-30 14:53 UTC · 6 months ago
- machine
- koh2
- parent
- 3c90975bc48e
↓ peach-browser-2563b303.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-protocol.test.ts+32 −0src/shared/sync-protocol.ts+31 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.13.23",4+ "version": "2.13.24",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.23",4+ "version": "2.13.24",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.23",3+ "version": "2.13.24",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/shared/sync-protocol.test.ts+32 −0
@@ -1,7 +1,9 @@11 import { describe, expect, it } from "vitest";22 33 import {4+ buildConflictedCopy,45 buildSyncManifest,6+ chooseConflictWinner,57 diffSyncManifests,68 hashVaultEntry,79 mergeSyncEntries,@@ -92,6 +94,36 @@9294 expect(selected).toEqual([expect.objectContaining({ id: "b", title: "Bank" })]);9395 selected[0]!.title = "Changed";9496 expect(source[1]!.title).toBe("Bank");97+ });98+99+ it("chooses the same conflict winner from either peer's perspective", () => {100+ // Both peers run chooseConflictWinner against the same hash pair but in101+ // opposite local/remote slots — the resulting winner-side must point at102+ // the same physical hash on both peers.103+ const peerA = chooseConflictWinner("aaaa", "bbbb");104+ const peerB = chooseConflictWinner("bbbb", "aaaa");105+ expect(peerA).toBe("local");106+ expect(peerB).toBe("remote");107+ });108+109+ it("breaks identical hashes by keeping local (no-op merge)", () => {110+ expect(chooseConflictWinner("xx", "xx")).toBe("local");111+ });112+113+ it("builds an idempotent conflicted copy id from the loser hash", () => {114+ const loser = entry({ id: "shared", title: "Edits" });115+ const archived = buildConflictedCopy(loser, "abcdef0123456789");116+ expect(archived.id).toBe("shared-conflict-abcdef01");117+ expect(archived.title).toBe("Edits (conflicted copy)");118+ // Re-running with the same loser hash returns the same id (no duplicate copies on re-sync)119+ expect(buildConflictedCopy(loser, "abcdef0123456789").id).toBe(archived.id);120+ });121+122+ it("conflicted copy does not mutate the source entry", () => {123+ const loser = entry({ id: "shared", title: "Original" });124+ buildConflictedCopy(loser, "deadbeef");125+ expect(loser.id).toBe("shared");126+ expect(loser.title).toBe("Original");95127 });96128 97129 it("merges incoming entries by newest modifiedAt and preserves tombstones", () => {src/shared/sync-protocol.ts+31 −0
@@ -71,6 +71,37 @@7171 return { requestFromRemote, offerToRemote, conflicts };7272 }7373 74+export type ConflictWinner = "local" | "remote";75+76+/**77+ * Equal-timestamp/different-hash conflict resolver.78+ *79+ * Both peers see the same pair of hashes for the conflicting entry, so80+ * sorting them lexicographically yields the same winner on each side81+ * without any extra round-trip — the peer holding the loser knows to82+ * archive its copy and accept the peer's payload.83+ */84+export function chooseConflictWinner(85+ localHash: string,86+ remoteHash: string87+): ConflictWinner {88+ if (localHash === remoteHash) return "local";89+ return localHash < remoteHash ? "local" : "remote";90+}91+92+/**93+ * A losing entry is preserved as a new vault entry rather than overwritten,94+ * so the user can still recover the rejected edit. The id is derived from95+ * the loser's id + content hash so re-running the same sync produces the96+ * same archive id (idempotent — never duplicates the conflicted copy).97+ */98+export function buildConflictedCopy(loser: VaultEntry, loserHash: string): VaultEntry {99+ const archived = structuredClone(loser);100+ archived.id = `${loser.id}-conflict-${loserHash.slice(0, 8)}`;101+ archived.title = `${loser.title ?? "Untitled"} (conflicted copy)`;102+ return archived;103+}104+74105 export function selectEntriesForSync(entries: VaultEntry[], ids: string[]): VaultEntry[] {75106 const wanted = new Set(ids);76107 return entries