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
4 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.ts+67 −0
manifest.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": {

view file

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

view file

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

view file

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);

view file