sync-trigger.ts

6.1 KB · raw

1// sync-trigger.ts — central sync dispatcher.2//3// SEV-1-A fix: the sync coordinator + transport were complete but4// never invoked from production code paths. This module is the single5// entry point for the three trigger conditions (vault change, popup6// foreground, and manual "Sync Now") and is the7// only place that calls `runSyncInitiator` outside tests.8//9// The actual per-device session runner is injected so the orchestrator10// can be unit-tested without WebSocket/Noise transport. Production wires11// in `productionSyncRunner` from `./sync-runner` at module load.1213import {14  getLastSyncAt,15  listPairedDevices,16  markLastSyncedAt,17  setLastSyncAt,18  setLastSyncResult,19} from "./paired-devices";20import type { PairedDevice } from "../shared/sync-types";2122export type SyncTriggerReason = "vault_change" | "foreground" | "manual";2324export type SyncRunStatus = "idle" | "syncing" | "error";2526export interface SyncState {27  status: SyncRunStatus;28  reason?: SyncTriggerReason;29  lastSyncAt?: number;30  entriesReceived?: number;31  entriesSent?: number;32  error?: string;33}3435export type SyncRunResult =36  | { ok: true; entriesReceived: number; entriesSent: number }37  | { ok: false; error: string };3839export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;4041interface PeachSyncTriggerState {42  syncInFlight: boolean;43  lastState: SyncState;44  runner: RunSyncWithDevice | null;45}4647function getSyncTriggerState(): PeachSyncTriggerState {48  const g = globalThis as typeof globalThis & {49    __peachSyncTriggerState?: PeachSyncTriggerState;50  };51  if (!g.__peachSyncTriggerState) {52    g.__peachSyncTriggerState = {53      syncInFlight: false,54      lastState: { status: "idle" },55      runner: null,56    };57  }58  return g.__peachSyncTriggerState;59}6061export function setSyncRunner(fn: RunSyncWithDevice | null): void {62  getSyncTriggerState().runner = fn;63}6465export function _resetSyncTriggerForTest(): void {66  const state = getSyncTriggerState();67  state.syncInFlight = false;68  state.lastState = { status: "idle" };69  state.runner = null;70}7172export function getSyncState(): SyncState {73  return getSyncTriggerState().lastState;74}7576/**77 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service78 * worker dies and restarts on the slightest provocation; without this the79 * popup's "Last synced N minutes ago" indicator resets to "never" every80 * time the SW respawns. Called once at startup from `entry.ts`.81 */82export async function hydratePersistedSyncState(): Promise<void> {83  const state = getSyncTriggerState();84  if (state.lastState.lastSyncAt !== undefined) return;85  const persisted = await getLastSyncAt();86  if (typeof persisted === "number") {87    state.lastState = { ...state.lastState, lastSyncAt: persisted };88  }89}9091export async function triggerSyncIfReachable(opts: {92  reason: SyncTriggerReason;93  force?: boolean;94}): Promise<void> {95  const state = getSyncTriggerState();96  // Claim the guard synchronously before any await, otherwise two97  // concurrent triggers could both pass the gate before either reached98  // the registry lookup.99  if (state.syncInFlight && !opts.force) return;100  state.syncInFlight = true;101102  try {103    const devices = await listPairedDevices();104    // Only native peers can accept a connection, so only they are sync hosts;105    // another extension is reachable solely via a relay, which is the relaying106    // device's job, not ours.107    //108    // "vouched" belongs here alongside "direct": a vouched device is one the109    // user has already confirmed out of the pending queue (MV-7), and the whole110    // point of mesh introduction is that it becomes reachable without its own111    // pairing code. Excluding it left confirmed devices permanently unsynced.112    const hosts = devices.filter(113      (d) =>114        (d.deviceType === "android" || d.deviceType === "ios") &&115        (d.trustSource === "direct" || d.trustSource === "vouched"),116    );117    if (hosts.length === 0) return;118    if (!state.runner) return;119120    setState({ status: "syncing", reason: opts.reason, error: undefined });121    await runSyncRounds(hosts);122  } finally {123    state.syncInFlight = false;124  }125}126127async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {128  const state = getSyncTriggerState();129  if (!state.runner) return;130131  let entriesReceived = 0;132  let entriesSent = 0;133  let anySucceeded = false;134  let firstError: string | undefined;135136  for (const host of hosts) {137    try {138      const result = await state.runner(host);139      if (!result.ok) {140        if (firstError === undefined) firstError = result.error;141        continue;142      }143      entriesReceived += result.entriesReceived;144      entriesSent += result.entriesSent;145      await markLastSyncedAt(host.deviceId, Date.now());146      anySucceeded = true;147    } catch (err) {148      if (firstError === undefined) {149        firstError = (err as Error)?.message ?? String(err);150      }151    }152  }153154  if (anySucceeded) {155    const now = Date.now();156    setState({157      status: "idle",158      lastSyncAt: now,159      entriesReceived,160      entriesSent,161      error: undefined,162    });163    // Persist so the popup's "last synced" indicator and entry counts164    // survive a SW restart. Best-effort — storage failures must not165    // surface as sync failures.166    void setLastSyncAt(now).catch(() => undefined);167    void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined);168  } else {169    setState({170      status: "error",171      error: firstError ?? "no_host_reachable",172    });173  }174}175176function setState(patch: Partial<SyncState>): void {177  const state = getSyncTriggerState();178  state.lastState = { ...state.lastState, ...patch };179  broadcast(state.lastState);180}181182function broadcast(state: SyncState): void {183  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;184  try {185    const maybeThenable = chrome.runtime.sendMessage({186      type: "SYNC_STATE_CHANGED",187      payload: state,188    });189    if (190      maybeThenable &&191      typeof (maybeThenable as Promise<unknown>).catch === "function"192    ) {193      (maybeThenable as Promise<unknown>).catch(() => {194        /* popup not listening — fine */195      });196    }197  } catch {198    /* messaging unavailable */199  }200}