sync-protocol.ts

5.0 KB · raw

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";7576/**77 * Equal-timestamp/different-hash conflict resolver.78 *79 * Both peers see the same pair of hashes for the conflicting entry, so80 * sorting them lexicographically yields the same winner on each side81 * without any extra round-trip — the peer holding the loser knows to82 * archive its copy and accept the peer's payload.83 */84export function chooseConflictWinner(85  localHash: string,86  remoteHash: string87): ConflictWinner {88  if (localHash === remoteHash) return "local";89  return localHash < remoteHash ? "local" : "remote";90}9192/**93 * A losing entry is preserved as a new vault entry rather than overwritten,94 * so the user can still recover the rejected edit. The id is derived from95 * the loser's id + content hash so re-running the same sync produces the96 * same archive id (idempotent — never duplicates the conflicted copy).97 */98export 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}