1import { VaultEntry } from "../shared/types";23export interface ManifestEntry {4 id: string;5 modifiedAt: number;6 deletedAt?: number;7 hash: string;8}910export interface ManifestDiffResult {11 sendToPeer: ManifestEntry[];12 requestFromPeer: string[];13 conflicts: ManifestEntry[];14}1516const GC_TTL_MS = 90 * 24 * 60 * 60 * 1000;1718export function diffManifests(19 localManifest: ManifestEntry[],20 remoteManifest: ManifestEntry[]21): ManifestDiffResult {22 const localMap = new Map<string, ManifestEntry>(localManifest.map((e) => [e.id, e]));23 const remoteMap = new Map<string, ManifestEntry>(remoteManifest.map((e) => [e.id, e]));2425 const sendToPeer: ManifestEntry[] = [];26 const requestFromPeer: string[] = [];27 const conflicts: ManifestEntry[] = [];2829 const allIds = new Set([...localMap.keys(), ...remoteMap.keys()]);3031 for (const id of allIds) {32 const local = localMap.get(id);33 const remote = remoteMap.get(id);3435 if (local && !remote) {36 sendToPeer.push(local);37 } else if (!local && remote) {38 requestFromPeer.push(id);39 } else if (local && remote) {40 if (local.modifiedAt === remote.modifiedAt) {41 if (local.hash !== remote.hash) {42 conflicts.push(resolveConflict(local, remote));43 }44 } else if (local.modifiedAt > remote.modifiedAt) {45 sendToPeer.push(local);46 } else {47 requestFromPeer.push(id);48 }49 }50 }5152 return { sendToPeer, requestFromPeer, conflicts };53}5455export function resolveConflict(a: ManifestEntry, b: ManifestEntry): ManifestEntry {56 return a.id >= b.id ? a : b;57}5859export function gcTombstones(entries: ManifestEntry[], nowMs: number): ManifestEntry[] {60 return entries.filter(61 (e) => !(e.deletedAt && e.deletedAt > 0 && nowMs - e.deletedAt > GC_TTL_MS)62 );63}6465export async function contentHash(entry: VaultEntry): Promise<string> {66 const { id: _id, modifiedAt: _modifiedAt, deletedAt: _deletedAt, ...rest } = entry;67 const json = JSON.stringify(rest);68 const encoded = new TextEncoder().encode(json);69 const digest = await crypto.subtle.digest("SHA-256", encoded);70 const bytes = new Uint8Array(digest).slice(0, 16);71 return Array.from(bytes)72 .map((b) => b.toString(16).padStart(2, "0"))73 .join("");74}7576export async function buildManifest(entries: VaultEntry[]): Promise<ManifestEntry[]> {77 return Promise.all(78 entries.map(async (entry) => ({79 id: entry.id,80 modifiedAt: entry.modifiedAt,81 deletedAt: entry.deletedAt,82 hash: await contentHash(entry),83 }))84 );85}