21a0838bcb4e
vault: applyMergedRemoteEntries — last-write-wins merge for remote entries+tombstones, no fireSyncTrigger to avoid feedback loop (v3.6.86)
- time
- 2026-05-21 16:27 UTC · 5 months ago
- machine
- koh2
- parent
- 0c7fd088b99c
↓ peach-browser-21a0838b.tar.gzatlas at this save
files changed4 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.ts+67 −0manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach",5- "version": "3.6.85",5+ "version": "3.6.86",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "3.6.85",4+ "version": "3.6.86",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": "3.6.85",3+ "version": "3.6.86",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/vault.ts+67 −0
@@ -1671,6 +1671,73 @@16711671 return entries.filter((e) => !!e.deletedAt);16721672 }16731673 1674+/**1675+ * Apply a batch of remote entries + tombstones from a chirp-sync peer.1676+ * Last-write-wins by `modifiedAt`; a tombstone supersedes a live entry when1677+ * `deletedAt > modifiedAt` on the local side. Mutates `entries` directly and1678+ * persists with reason `sync_received` — does NOT call `fireSyncTrigger`, so1679+ * applying a sync round cannot cascade into another outbound sync.1680+ *1681+ * `applied` counts entries that actually changed local state (new add,1682+ * overwrite-by-newer, or new tombstone). `received` is the raw payload size1683+ * the caller can surface in the sync result UI.1684+ */1685+export async function applyMergedRemoteEntries(1686+ remoteEntries: VaultEntry[],1687+ remoteTombstones: Record<string, number>,1688+): Promise<{ received: number; applied: number; conflicts: number }> {1689+ if (!derivedKey) throw new Error("vault_locked");1690+1691+ const received = remoteEntries.length + Object.keys(remoteTombstones).length;1692+ let applied = 0;1693+ let conflicts = 0;1694+1695+ const byId = new Map<string, VaultEntry>();1696+ for (const e of entries) byId.set(e.id, e);1697+1698+ for (const remote of remoteEntries) {1699+ const sanitized = sanitizeRestoredEntry(remote);1700+ if (!sanitized) continue;1701+ const tombstoneTs = remoteTombstones[sanitized.id];1702+ if (tombstoneTs !== undefined && tombstoneTs >= sanitized.modifiedAt) {1703+ continue;1704+ }1705+ const local = byId.get(sanitized.id);1706+ if (!local) {1707+ entries.push(sanitized);1708+ byId.set(sanitized.id, sanitized);1709+ applied += 1;1710+ } else if (sanitized.modifiedAt > local.modifiedAt) {1711+ const idx = entries.findIndex((e) => e.id === local.id);1712+ if (idx !== -1) {1713+ entries[idx] = sanitized;1714+ byId.set(sanitized.id, sanitized);1715+ applied += 1;1716+ if (!local.deletedAt) conflicts += 1;1717+ }1718+ }1719+ }1720+1721+ for (const [id, deletedAt] of Object.entries(remoteTombstones)) {1722+ if (typeof deletedAt !== "number" || !Number.isFinite(deletedAt)) continue;1723+ const local = byId.get(id);1724+ if (!local) continue;1725+ if (local.deletedAt && local.deletedAt >= deletedAt) continue;1726+ if (deletedAt < local.modifiedAt) continue;1727+ const idx = entries.findIndex((e) => e.id === id);1728+ if (idx !== -1) {1729+ entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };1730+ applied += 1;1731+ }1732+ }1733+1734+ if (applied > 0) {1735+ await persistEntries("sync_received");1736+ buildDomainIndexSync(entries);1737+ }1738+ return { received, applied, conflicts };1739+}1740+16741741 export async function restoreEntry(id: string): Promise<void> {16751742 if (!derivedKey) throw new Error("vault_locked");16761743 const entry = entries.find((e) => e.id === id && e.deletedAt);