e6e751939095

feat: Add sync manifest diff helpers (v2.13.13)

time
2026-04-30 13:57 UTC · 6 months ago
machine
koh2
parent
c5f5d4f09257
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-protocol.test.ts+115 −0src/shared/sync-protocol.ts+136 −0src/shared/sync-types.ts+8 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.12",4+  "version": "2.13.13",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.12",4+  "version": "2.13.13",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.12",3+  "version": "2.13.13",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/shared/sync-protocol.test.ts+115 −0
@@ -0,0 +1,115 @@1+import { describe, expect, it } from "vitest";2+3+import {4+  buildSyncManifest,5+  diffSyncManifests,6+  hashVaultEntry,7+  mergeSyncEntries,8+  selectEntriesForSync,9+} from "./sync-protocol";10+import type { VaultEntry } from "./types";11+12+function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {13+  return {14+    id: "entry-1",15+    type: "login",16+    title: "GitHub",17+    username: "june",18+    password: "secret",19+    url: "https://github.com",20+    createdAt: 1_000,21+    modifiedAt: 1_000,22+    ...overrides,23+  };24+}25+26+describe("sync protocol helpers", () => {27+  it("hashes entries canonically regardless of object key insertion order", async () => {28+    const a = entry({ tags: ["work", "code"] });29+    const b = {30+      tags: ["work", "code"],31+      modifiedAt: 1_000,32+      createdAt: 1_000,33+      url: "https://github.com",34+      password: "secret",35+      username: "june",36+      title: "GitHub",37+      type: "login",38+      id: "entry-1",39+    } as VaultEntry;40+41+    await expect(hashVaultEntry(a)).resolves.toBe(await hashVaultEntry(b));42+  });43+44+  it("builds a stable manifest sorted by entry id", async () => {45+    const manifest = await buildSyncManifest([46+      entry({ id: "b", modifiedAt: 20 }),47+      entry({ id: "a", modifiedAt: 10, deletedAt: 30 }),48+    ]);49+50+    expect(manifest).toEqual([51+      {52+        id: "a",53+        modifiedAt: 10,54+        deletedAt: 30,55+        hash: expect.stringMatching(/^[0-9a-f]{64}$/),56+      },57+      {58+        id: "b",59+        modifiedAt: 20,60+        hash: expect.stringMatching(/^[0-9a-f]{64}$/),61+      },62+    ]);63+  });64+65+  it("diffs manifests into request, offer, and conflict buckets", () => {66+    const diff = diffSyncManifests(67+      [68+        { id: "local-only", modifiedAt: 10, hash: "a" },69+        { id: "newer-local", modifiedAt: 20, hash: "b" },70+        { id: "newer-remote", modifiedAt: 5, hash: "c" },71+        { id: "same-time", modifiedAt: 7, hash: "d" },72+      ],73+      [74+        { id: "remote-only", modifiedAt: 10, hash: "e" },75+        { id: "newer-local", modifiedAt: 12, hash: "f" },76+        { id: "newer-remote", modifiedAt: 30, hash: "g" },77+        { id: "same-time", modifiedAt: 7, hash: "h" },78+      ]79+    );80+81+    expect(diff).toEqual({82+      requestFromRemote: ["newer-remote", "remote-only"],83+      offerToRemote: ["local-only", "newer-local"],84+      conflicts: ["same-time"],85+    });86+  });87+88+  it("selects sync entries by id without sharing mutable references", () => {89+    const source = [entry({ id: "a" }), entry({ id: "b", title: "Bank" })];90+    const selected = selectEntriesForSync(source, ["b"]);91+92+    expect(selected).toEqual([expect.objectContaining({ id: "b", title: "Bank" })]);93+    selected[0]!.title = "Changed";94+    expect(source[1]!.title).toBe("Bank");95+  });96+97+  it("merges incoming entries by newest modifiedAt and preserves tombstones", () => {98+    const local = [99+      entry({ id: "same", modifiedAt: 10, title: "old" }),100+      entry({ id: "local-newer", modifiedAt: 50, title: "keep" }),101+    ];102+    const incoming = [103+      entry({ id: "same", modifiedAt: 20, title: "new" }),104+      entry({ id: "local-newer", modifiedAt: 40, title: "skip" }),105+      entry({ id: "deleted", modifiedAt: 30, deletedAt: 30, title: "trash" }),106+    ];107+108+    const merged = mergeSyncEntries(local, incoming);109+110+    expect(merged).toMatchObject({ added: 1, updated: 1, skipped: 1 });111+    expect(merged.entries.find((item) => item.id === "same")?.title).toBe("new");112+    expect(merged.entries.find((item) => item.id === "local-newer")?.title).toBe("keep");113+    expect(merged.entries.find((item) => item.id === "deleted")?.deletedAt).toBe(30);114+  });115+});

view file

src/shared/sync-protocol.ts+136 −0
@@ -0,0 +1,136 @@1+import type { VaultEntry } from "./types";2+import type { SyncManifestEntry } from "./sync-types";3+4+export interface SyncDiff {5+  requestFromRemote: string[];6+  offerToRemote: string[];7+  conflicts: string[];8+}9+10+export interface SyncMergeResult {11+  entries: VaultEntry[];12+  added: number;13+  updated: number;14+  skipped: number;15+}16+17+export async function buildSyncManifest(entries: VaultEntry[]): Promise<SyncManifestEntry[]> {18+  const manifest = await Promise.all(19+    entries.map(async (entry) => ({20+      id: entry.id,21+      modifiedAt: entry.modifiedAt ?? entry.createdAt ?? 0,22+      ...(entry.deletedAt !== undefined ? { deletedAt: entry.deletedAt } : {}),23+      hash: await hashVaultEntry(entry),24+    }))25+  );26+27+  return manifest.sort((a, b) => a.id.localeCompare(b.id));28+}29+30+export async function hashVaultEntry(entry: VaultEntry): Promise<string> {31+  const encoded = new TextEncoder().encode(canonicalJson(entry));32+  const digest = await crypto.subtle.digest("SHA-256", encoded);33+  return hex(new Uint8Array(digest));34+}35+36+export function diffSyncManifests(37+  local: SyncManifestEntry[],38+  remote: SyncManifestEntry[]39+): SyncDiff {40+  const localById = new Map(local.map((entry) => [entry.id, entry]));41+  const remoteById = new Map(remote.map((entry) => [entry.id, entry]));42+  const ids = [...new Set([...localById.keys(), ...remoteById.keys()])].sort();43+44+  const requestFromRemote: string[] = [];45+  const offerToRemote: string[] = [];46+  const conflicts: string[] = [];47+48+  for (const id of ids) {49+    const ours = localById.get(id);50+    const theirs = remoteById.get(id);51+52+    if (!ours && theirs) {53+      requestFromRemote.push(id);54+      continue;55+    }56+    if (ours && !theirs) {57+      offerToRemote.push(id);58+      continue;59+    }60+    if (!ours || !theirs || ours.hash === theirs.hash) continue;61+62+    if (ours.modifiedAt > theirs.modifiedAt) {63+      offerToRemote.push(id);64+    } else if (theirs.modifiedAt > ours.modifiedAt) {65+      requestFromRemote.push(id);66+    } else {67+      conflicts.push(id);68+    }69+  }70+71+  return { requestFromRemote, offerToRemote, conflicts };72+}73+74+export function selectEntriesForSync(entries: VaultEntry[], ids: string[]): VaultEntry[] {75+  const wanted = new Set(ids);76+  return entries77+    .filter((entry) => wanted.has(entry.id))78+    .map((entry) => structuredClone(entry));79+}80+81+export function mergeSyncEntries(82+  local: VaultEntry[],83+  incoming: VaultEntry[]84+): SyncMergeResult {85+  const merged = local.map((entry) => structuredClone(entry));86+  const byId = new Map(merged.map((entry, index) => [entry.id, { entry, index }]));87+  let added = 0;88+  let updated = 0;89+  let skipped = 0;90+91+  for (const next of incoming) {92+    const existing = byId.get(next.id);93+    const cloned = structuredClone(next);94+95+    if (!existing) {96+      byId.set(cloned.id, { entry: cloned, index: merged.length });97+      merged.push(cloned);98+      added += 1;99+      continue;100+    }101+102+    const existingModified = existing.entry.modifiedAt ?? existing.entry.createdAt ?? 0;103+    const nextModified = cloned.modifiedAt ?? cloned.createdAt ?? 0;104+    if (nextModified > existingModified) {105+      merged[existing.index] = cloned;106+      byId.set(cloned.id, { entry: cloned, index: existing.index });107+      updated += 1;108+    } else {109+      skipped += 1;110+    }111+  }112+113+  return { entries: merged, added, updated, skipped };114+}115+116+function canonicalJson(value: unknown): string {117+  return JSON.stringify(canonicalize(value));118+}119+120+function canonicalize(value: unknown): unknown {121+  if (Array.isArray(value)) return value.map(canonicalize);122+  if (!value || typeof value !== "object") return value;123+124+  const sorted: Record<string, unknown> = {};125+  for (const key of Object.keys(value).sort()) {126+    const next = (value as Record<string, unknown>)[key];127+    if (next !== undefined) sorted[key] = canonicalize(next);128+  }129+  return sorted;130+}131+132+function hex(bytes: Uint8Array): string {133+  return Array.from(bytes)134+    .map((byte) => byte.toString(16).padStart(2, "0"))135+    .join("");136+}

view file

src/shared/sync-types.ts+8 −1
@@ -18,9 +18,16 @@1818   lastSyncAt?: number;1919 }2020 21+export interface SyncManifestEntry {22+  id: string;23+  modifiedAt: number;24+  deletedAt?: number;25+  hash: string;26+}27+2128 export type SyncMessage =2229   | { type: "SYNC_HELLO"; deviceId: string; clockMs: number; vaultVersion: number }23-  | { type: "SYNC_MANIFEST"; entries: Array<{ id: string; modifiedAt: number; deletedAt?: number; hash: string }> }30+  | { type: "SYNC_MANIFEST"; entries: SyncManifestEntry[] }2431   | { type: "SYNC_REQUEST"; ids: string[] }2532   | { type: "SYNC_ENTRIES"; entries: EncryptedSyncEntry[] }2633   | { type: "SYNC_DONE" }

view file