e6e751939095
feat: Add sync manifest diff helpers (v2.13.13)
- time
- 2026-04-30 13:57 UTC · 6 months ago
- machine
- koh2
- parent
- c5f5d4f09257
↓ peach-browser-e6e75193.tar.gzatlas at this save
files changed6 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 −1manifest.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": {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",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": {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+});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+}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" }