sync-coordinator.ts

4.2 KB · raw

1import type { SyncTransport } from '../shared/websocket-sync-transport';2import type { SyncableDevice } from '../shared/sync-types';3import { encodeSyncMessage, decodeSyncMessage } from '../shared/sync-wire';45export interface SyncDeps {6  getVaultBlobForSync: () => Promise<{7    blob: string;8    timestamp: number;9    license?: string;10    devices: SyncableDevice[];11  } | null>;12  applyIncomingVault: (13    blob: string,14    timestamp: number,15    license?: string,16    devices?: SyncableDevice[],17  ) => Promise<boolean>;18  isVaultUnlocked: () => boolean;19}2021export interface SyncResult {22  status: 'completed' | 'skipped' | 'errored';23  applied: boolean;24  error?: string;25}2627// Full bidirectional vault sync session — last-write-wins on timestamp.28// Initiator sends SYNC_HELLO, responder sends its vault first, then initiator29// sends its vault. Both sides apply if the remote is newer.30export async function runBlobSyncSession(31  role: 'initiator' | 'responder',32  transport: SyncTransport,33  deps: SyncDeps,34): Promise<SyncResult> {35  if (!deps.isVaultUnlocked()) {36    return { status: 'skipped', applied: false, error: 'vault_locked' };37  }3839  try {40    if (role === 'initiator') {41      await transport.send(encodeSyncMessage({ type: 'SYNC_HELLO' }));4243      // Receive remote vault first44      const remoteMsg = decodeSyncMessage(await transport.receive());45      let applied = false;4647      if (remoteMsg.type === 'SYNC_VAULT_SEND') {48        applied = await deps.applyIncomingVault(49          remoteMsg.blob,50          remoteMsg.timestamp,51          remoteMsg.license,52          remoteMsg.devices,53        );54        await transport.send(encodeSyncMessage({ type: 'SYNC_VAULT_ACK', applied }));55      } else if (remoteMsg.type !== 'SYNC_DONE') {56        throw new Error(`unexpected message: ${remoteMsg.type}`);57      }5859      // Send our vault60      const local = await deps.getVaultBlobForSync();61      if (local) {62        await transport.send(encodeSyncMessage({63          type: 'SYNC_VAULT_SEND',64          blob: local.blob,65          timestamp: local.timestamp,66          license: local.license,67          devices: local.devices,68        }));69        const ack = decodeSyncMessage(await transport.receive());70        if (ack.type !== 'SYNC_VAULT_ACK') throw new Error(`expected ACK, got ${ack.type}`);71      }7273      await transport.send(encodeSyncMessage({ type: 'SYNC_DONE' }));74      await transport.receive(); // wait for remote SYNC_DONE75      await transport.close();76      return { status: 'completed', applied };7778    } else {79      // Responder: wait for SYNC_HELLO80      const hello = decodeSyncMessage(await transport.receive());81      if (hello.type !== 'SYNC_HELLO') throw new Error(`expected SYNC_HELLO, got ${hello.type}`);8283      // Send our vault first84      const local = await deps.getVaultBlobForSync();85      if (local) {86        await transport.send(encodeSyncMessage({87          type: 'SYNC_VAULT_SEND',88          blob: local.blob,89          timestamp: local.timestamp,90          license: local.license,91          devices: local.devices,92        }));93        const ack = decodeSyncMessage(await transport.receive());94        if (ack.type !== 'SYNC_VAULT_ACK') throw new Error(`expected ACK, got ${ack.type}`);95      } else {96        await transport.send(encodeSyncMessage({ type: 'SYNC_DONE' }));97      }9899      // Receive initiator's vault100      const remoteMsg = decodeSyncMessage(await transport.receive());101      let applied = false;102      if (remoteMsg.type === 'SYNC_VAULT_SEND') {103        applied = await deps.applyIncomingVault(104          remoteMsg.blob,105          remoteMsg.timestamp,106          remoteMsg.license,107          remoteMsg.devices,108        );109        await transport.send(encodeSyncMessage({ type: 'SYNC_VAULT_ACK', applied }));110      }111112      // Wait for SYNC_DONE then echo it113      const done = decodeSyncMessage(await transport.receive());114      if (done.type === 'SYNC_DONE') {115        await transport.send(encodeSyncMessage({ type: 'SYNC_DONE' }));116      }117118      await transport.close();119      return { status: 'completed', applied };120    }121  } catch (err) {122    await transport.close().catch(() => {});123    return { status: 'errored', applied: false, error: String(err) };124  }125}