1import type { VaultEntry } from "./types";2import type { SyncManifestEntry } from "./sync-types";34export interface SyncDiff {5 requestFromRemote: string[];6 offerToRemote: string[];7 conflicts: string[];8}910export interface SyncMergeResult {11 entries: VaultEntry[];12 added: number;13 updated: number;14 skipped: number;15}1617export 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 );2627 return manifest.sort((a, b) => a.id.localeCompare(b.id));28}2930export 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}3536export 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();4344 const requestFromRemote: string[] = [];45 const offerToRemote: string[] = [];46 const conflicts: string[] = [];4748 for (const id of ids) {49 const ours = localById.get(id);50 const theirs = remoteById.get(id);5152 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;6162 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 }7071 return { requestFromRemote, offerToRemote, conflicts };72}7374export 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}8081export 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;9091 for (const next of incoming) {92 const existing = byId.get(next.id);93 const cloned = structuredClone(next);9495 if (!existing) {96 byId.set(cloned.id, { entry: cloned, index: merged.length });97 merged.push(cloned);98 added += 1;99 continue;100 }101102 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 }112113 return { entries: merged, added, updated, skipped };114}115116function canonicalJson(value: unknown): string {117 return JSON.stringify(canonicalize(value));118}119120function canonicalize(value: unknown): unknown {121 if (Array.isArray(value)) return value.map(canonicalize);122 if (!value || typeof value !== "object") return value;123124 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}131132function hex(bytes: Uint8Array): string {133 return Array.from(bytes)134 .map((byte) => byte.toString(16).padStart(2, "0"))135 .join("");136}