import type { VaultEntry } from "./types"; import type { SyncManifestEntry } from "./sync-types"; export interface SyncDiff { requestFromRemote: string[]; offerToRemote: string[]; conflicts: string[]; } export interface SyncMergeResult { entries: VaultEntry[]; added: number; updated: number; skipped: number; } export async function buildSyncManifest(entries: VaultEntry[]): Promise { const manifest = await Promise.all( entries.map(async (entry) => ({ id: entry.id, modifiedAt: entry.modifiedAt ?? entry.createdAt ?? 0, ...(entry.deletedAt !== undefined ? { deletedAt: entry.deletedAt } : {}), hash: await hashVaultEntry(entry), })) ); return manifest.sort((a, b) => a.id.localeCompare(b.id)); } export async function hashVaultEntry(entry: VaultEntry): Promise { const encoded = new TextEncoder().encode(canonicalJson(entry)); const digest = await crypto.subtle.digest("SHA-256", encoded); return hex(new Uint8Array(digest)); } export function diffSyncManifests( local: SyncManifestEntry[], remote: SyncManifestEntry[] ): SyncDiff { const localById = new Map(local.map((entry) => [entry.id, entry])); const remoteById = new Map(remote.map((entry) => [entry.id, entry])); const ids = [...new Set([...localById.keys(), ...remoteById.keys()])].sort(); const requestFromRemote: string[] = []; const offerToRemote: string[] = []; const conflicts: string[] = []; for (const id of ids) { const ours = localById.get(id); const theirs = remoteById.get(id); if (!ours && theirs) { requestFromRemote.push(id); continue; } if (ours && !theirs) { offerToRemote.push(id); continue; } if (!ours || !theirs || ours.hash === theirs.hash) continue; if (ours.modifiedAt > theirs.modifiedAt) { offerToRemote.push(id); } else if (theirs.modifiedAt > ours.modifiedAt) { requestFromRemote.push(id); } else { conflicts.push(id); } } return { requestFromRemote, offerToRemote, conflicts }; } export function selectEntriesForSync(entries: VaultEntry[], ids: string[]): VaultEntry[] { const wanted = new Set(ids); return entries .filter((entry) => wanted.has(entry.id)) .map((entry) => structuredClone(entry)); } export function mergeSyncEntries( local: VaultEntry[], incoming: VaultEntry[] ): SyncMergeResult { const merged = local.map((entry) => structuredClone(entry)); const byId = new Map(merged.map((entry, index) => [entry.id, { entry, index }])); let added = 0; let updated = 0; let skipped = 0; for (const next of incoming) { const existing = byId.get(next.id); const cloned = structuredClone(next); if (!existing) { byId.set(cloned.id, { entry: cloned, index: merged.length }); merged.push(cloned); added += 1; continue; } const existingModified = existing.entry.modifiedAt ?? existing.entry.createdAt ?? 0; const nextModified = cloned.modifiedAt ?? cloned.createdAt ?? 0; if (nextModified > existingModified) { merged[existing.index] = cloned; byId.set(cloned.id, { entry: cloned, index: existing.index }); updated += 1; } else { skipped += 1; } } return { entries: merged, added, updated, skipped }; } function canonicalJson(value: unknown): string { return JSON.stringify(canonicalize(value)); } function canonicalize(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonicalize); if (!value || typeof value !== "object") return value; const sorted: Record = {}; for (const key of Object.keys(value).sort()) { const next = (value as Record)[key]; if (next !== undefined) sorted[key] = canonicalize(next); } return sorted; } function hex(bytes: Uint8Array): string { return Array.from(bytes) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); }