123456789101112131415161718192021import type { ChirpSyncMessage } from "../shared/sync-types";22import type { ChirpSyncSession } from "../shared/chirp-sync-transport";2324const SESSION_TIMEOUT_MS = 30_000;2526272829303132export interface ChirpSyncVaultAdapter {33 computeVaultId(): Promise<string>;34 getOurDeviceId(): Promise<string>;35 36 buildEntryManifest(): Promise<Record<string, number>>;37 38 buildTombstoneManifest(): Promise<Record<string, number>>;39 40 getEncryptedEntries(entryIds: string[]): Promise<Record<string, string>>;41 42 applyEntries(entries: Record<string, string>): Promise<void>;43 44 applyTombstones(tombstones: Record<string, number>): Promise<void>;45}4647export interface SyncOutcome {48 status: "completed" | "vault_mismatch" | "errored" | "timeout";49 entriesReceived: number;50 entriesSent: number;51 tombstonesReceived: number;52 tombstonesSent: number;53 error?: string;54}5556575859606162export async function runChirpSyncInitiator(63 session: ChirpSyncSession,64 vault: ChirpSyncVaultAdapter,65): Promise<SyncOutcome> {66 const result: SyncOutcome = {67 status: "errored",68 entriesReceived: 0,69 entriesSent: 0,70 tombstonesReceived: 0,71 tombstonesSent: 0,72 };7374 const timer = scheduleTimeout(SESSION_TIMEOUT_MS);75 try {76 const ourVaultId = await vault.computeVaultId();77 const ourDeviceId = await vault.getOurDeviceId();7879 await sendMessage(session, { type: "SYNC_HELLO", vaultId: ourVaultId, deviceId: ourDeviceId });80 const helloIn = await receiveMessage(session, timer);81 if (helloIn.type !== "SYNC_HELLO") {82 return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);83 }84 if (helloIn.vaultId !== ourVaultId) {85 await sendMessage(session, { type: "SYNC_ERROR", error: "vault_id_mismatch" });86 result.status = "vault_mismatch";87 return result;88 }8990 const ourEntries = await vault.buildEntryManifest();91 const ourTombstones = await vault.buildTombstoneManifest();92 await sendMessage(session, {93 type: "SYNC_MANIFEST",94 entries: ourEntries,95 tombstones: ourTombstones,96 });97 const manifestIn = await receiveMessage(session, timer);98 if (manifestIn.type !== "SYNC_MANIFEST") {99 return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);100 }101102 const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);103 await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });104105 const requestIn = await receiveMessage(session, timer);106 if (requestIn.type !== "SYNC_REQUEST") {107 return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);108 }109 110 111 112113 const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);114 await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });115 result.entriesSent = Object.keys(ourPayload).length;116117 if (wantFromPeer.length > 0) {118 const entriesIn = await receiveMessage(session, timer);119 if (entriesIn.type !== "SYNC_ENTRIES") {120 return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);121 }122 await vault.applyEntries(entriesIn.entries);123 result.entriesReceived = Object.keys(entriesIn.entries).length;124 }125126 127 128 const newerTombstonesForPeer: Record<string, number> = {};129 for (const [id, ts] of Object.entries(ourTombstones)) {130 const peerTs = manifestIn.tombstones[id] ?? 0;131 if (ts > peerTs) newerTombstonesForPeer[id] = ts;132 }133 await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });134 result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;135136 const tombstonesIn = await receiveMessage(session, timer);137 if (tombstonesIn.type !== "SYNC_TOMBSTONES") {138 return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);139 }140 await vault.applyTombstones(tombstonesIn.tombstones);141 result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;142143 await sendMessage(session, { type: "SYNC_DONE" });144 const done = await receiveMessage(session, timer);145 if (done.type !== "SYNC_DONE") {146 return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);147 }148 result.status = "completed";149 return result;150 } catch (err) {151 if ((err as Error).message === "chirp_sync_timeout") {152 result.status = "timeout";153 result.error = "session timed out after 30s";154 return result;155 }156 return errorOutcome(result, (err as Error).message);157 } finally {158 timer.cancel();159 await session.close().catch(() => undefined);160 }161}162163function diffNewer(remote: Record<string, number>, local: Record<string, number>): string[] {164 const want: string[] = [];165 for (const [id, ts] of Object.entries(remote)) {166 const ours = local[id];167 if (ours === undefined || ts > ours) want.push(id);168 }169 return want;170}171172interface Timer {173 signal: AbortSignal;174 cancel(): void;175}176177function scheduleTimeout(ms: number): Timer {178 const ctrl = new AbortController();179 const t = setTimeout(() => ctrl.abort(new Error("chirp_sync_timeout")), ms);180 return {181 signal: ctrl.signal,182 cancel: () => clearTimeout(t),183 };184}185186async function sendMessage(session: ChirpSyncSession, msg: ChirpSyncMessage): Promise<void> {187 session.send(new TextEncoder().encode(JSON.stringify(msg)));188}189190async function receiveMessage(session: ChirpSyncSession, timer: Timer): Promise<ChirpSyncMessage> {191 const pending = session.receive();192 const aborted = new Promise<never>((_, reject) => {193 if (timer.signal.aborted) reject(new Error("chirp_sync_timeout"));194 timer.signal.addEventListener("abort", () => reject(new Error("chirp_sync_timeout")));195 });196 const bytes = await Promise.race([pending, aborted]);197 const parsed = JSON.parse(new TextDecoder().decode(bytes));198 if (typeof parsed?.type !== "string") throw new Error("malformed SyncMessage");199 return parsed as ChirpSyncMessage;200}201202function errorOutcome(outcome: SyncOutcome, error: string): SyncOutcome {203 outcome.status = "errored";204 outcome.error = error;205 return outcome;206}