2563b3036de4

feat: Add deterministic sync conflict resolver (v2.13.24)

- Add chooseConflictWinner: lex-compare hashes so both peers pick the same winner - Identical hashes default to local (no-op merge) - Add buildConflictedCopy: archive loser as a new entry with id derived from loser hash, idempotent on re-sync - Title suffix " (conflicted copy)"; source entry not mutated - Cover symmetric peer perspectives, identical-hash no-op, idempotent archive id, immutability Verification: npm typecheck, focused sync-protocol 9/9, npm test 279/279, npm run build clean.

time
2026-04-30 14:53 UTC · 6 months ago
machine
koh2
parent
3c90975bc48e
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-protocol.test.ts+32 −0src/shared/sync-protocol.ts+31 −0
manifest.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": {

view file

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",

view file

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": {

view file

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", () => {

view file

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

view file