sync-protocol.ts

3.9 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 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}