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 type ConflictWinner = "local" | "remote";75767778798081828384export function chooseConflictWinner(85 localHash: string,86 remoteHash: string87): ConflictWinner {88 if (localHash === remoteHash) return "local";89 return localHash < remoteHash ? "local" : "remote";90}9192939495969798export function buildConflictedCopy(loser: VaultEntry, loserHash: string): VaultEntry {99 const archived = structuredClone(loser);100 archived.id = `${loser.id}-conflict-${loserHash.slice(0, 8)}`;101 archived.title = `${loser.title ?? "Untitled"} (conflicted copy)`;102 return archived;103}104105export function selectEntriesForSync(entries: VaultEntry[], ids: string[]): VaultEntry[] {106 const wanted = new Set(ids);107 return entries108 .filter((entry) => wanted.has(entry.id))109 .map((entry) => structuredClone(entry));110}111112export function mergeSyncEntries(113 local: VaultEntry[],114 incoming: VaultEntry[]115): SyncMergeResult {116 const merged = local.map((entry) => structuredClone(entry));117 const byId = new Map(merged.map((entry, index) => [entry.id, { entry, index }]));118 let added = 0;119 let updated = 0;120 let skipped = 0;121122 for (const next of incoming) {123 const existing = byId.get(next.id);124 const cloned = structuredClone(next);125126 if (!existing) {127 byId.set(cloned.id, { entry: cloned, index: merged.length });128 merged.push(cloned);129 added += 1;130 continue;131 }132133 const existingModified = existing.entry.modifiedAt ?? existing.entry.createdAt ?? 0;134 const nextModified = cloned.modifiedAt ?? cloned.createdAt ?? 0;135 if (nextModified > existingModified) {136 merged[existing.index] = cloned;137 byId.set(cloned.id, { entry: cloned, index: existing.index });138 updated += 1;139 } else {140 skipped += 1;141 }142 }143144 return { entries: merged, added, updated, skipped };145}146147function canonicalJson(value: unknown): string {148 return JSON.stringify(canonicalize(value));149}150151function canonicalize(value: unknown): unknown {152 if (Array.isArray(value)) return value.map(canonicalize);153 if (!value || typeof value !== "object") return value;154155 const sorted: Record<string, unknown> = {};156 for (const key of Object.keys(value).sort()) {157 const next = (value as Record<string, unknown>)[key];158 if (next !== undefined) sorted[key] = canonicalize(next);159 }160 return sorted;161}162163function hex(bytes: Uint8Array): string {164 return Array.from(bytes)165 .map((byte) => byte.toString(16).padStart(2, "0"))166 .join("");167}