sync-outcome.ts

7.1 KB · raw

1// Why a sync run ended, in a form both the service worker and the popup can2// name. Before this existed, "no eligible host", "runner not wired" and3// "synced fine, nothing to do" were all a bare `return` — indistinguishable4// to the user and to us. A failed sync and a successful no-op must never5// look the same again.67export type SyncOutcome =8  | "completed_changes"9  | "completed_no_changes"10  | "no_paired_devices"11  | "no_eligible_host"12  | "waiting_for_peer"13  | "runner_unavailable"14  | "sync_disabled"15  | "private_relay_unset"16  | "vault_locked"17  | "relay_unreachable"18  | "handshake_failed"19  | "vault_id_mismatch"20  | "peer_protocol_outdated"21  | "reset_required"22  | "sync_generation_unknown"23  | "sync_timeout"24  | "connection_lost"25  | "trust_data_corrupted"26  | "run_failed";2728/**29 * Outcomes where sync did not do what the user asked, so the UI shows red.30 *31 * Network-class failures (relay/handshake/timeout) are NOT in this set —32 * with a 10-minute background alarm the normal state for most minutes is33 * "the relay didn't answer this time". That is the network, not the app,34 * and the next alarm tick will retry. They live in `WARNINGS` so the35 * indicator goes yellow instead of red.36 */37const FAILURES: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([38  "no_eligible_host",39  "runner_unavailable",40  "private_relay_unset",41  "vault_id_mismatch",42  "peer_protocol_outdated",43  // §11: a device that owes a secure reset, or whose generation cannot be44  // read, has not synced and must be told so.45  "reset_required",46  "sync_generation_unknown",47  // §18.4: a corrupt persisted ledger must not be read as empty — it is a48  // trust integrity error, not a network miss or a healthy empty state.49  "trust_data_corrupted",50  "run_failed",51]);5253/**54 * Network-class outcomes — the dial did not reach the peer this attempt.55 * The alarm keeps trying, so this is warn (yellow), not error (red).56 */57const WARNINGS: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([58  "relay_unreachable",59  "handshake_failed",60  "sync_timeout",61  "connection_lost",62  // vault_locked is the reader's vault being closed — not a misconfiguration,63  // not a broken sync. The user is in another app or hasn't unlocked yet; the64  // next unlock + retry will succeed. Same alarm-keeps-trying semantics as a65  // missed dial, so it lives here rather than in FAILURES.66  "vault_locked",67]);6869/**70 * A transport that hung up, as opposed to a protocol or vault problem.71 *72 * Lives here because two callers need the same judgement and must not drift:73 * the coordinator (a close while waiting for the peer's final SYNC_DONE echo74 * is a completed sync) and the trigger's error classifier (a close anywhere75 * else is network-class, so yellow rather than red).76 *77 * Deliberately does not match SyncFrameTooLargeError's "WebSocket close 1009"78 * — an oversized frame is a misbehaving peer and must keep failing.79 */80export function isConnectionClosedError(err: unknown): boolean {81  const message = err instanceof Error ? err.message : String(err);82  return /websocket closed|connection closed/i.test(message);83}8485export function isSyncFailure(outcome: SyncOutcome): boolean {86  return FAILURES.has(outcome);87}8889export function isSyncWarning(outcome: SyncOutcome): boolean {90  return WARNINGS.has(outcome);91}9293export interface SyncOutcomeSummary {94  status?: "idle" | "syncing" | "error";95  outcome?: SyncOutcome;96  entriesSent?: number;97  entriesReceived?: number;98  detail?: string;99  /** When the last attempt ended, success or not. */100  finishedAt?: number;101}102103/**104 * The one indicator light. `busy` pulses green, `ok` is solid green, `warn`105 * is yellow (a transient network miss — the next alarm tick retries),106 * `error` is red (the app is actually broken), `idle` is unlit. Nothing107 * that is actually broken may resolve to `ok` — a paired-but-unsyncable108 * device is red, not green. A dial that didn't reach the peer is yellow,109 * because the alarm will try again.110 */111export type SyncIndicator = "busy" | "ok" | "warn" | "error" | "idle";112113export function syncIndicator(s: SyncOutcomeSummary, pairedCount: number): SyncIndicator {114  if (s.status === "syncing") return "busy";115  if (s.outcome && isSyncFailure(s.outcome)) return "error";116  if (s.outcome && isSyncWarning(s.outcome)) return "warn";117  if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") {118    return "ok";119  }120  if (s.outcome === "waiting_for_peer") return "ok";121  // Paired with a healthy history but no attempt since this SW woke: still a122  // successful pair, which is what the light is claiming.123  if (pairedCount > 0 && s.outcome === undefined) return "ok";124  return "idle";125}126127const INDICATOR_LABEL: Record<SyncIndicator, string> = {128  busy: "Syncing",129  ok: "Sync healthy",130  warn: "Last sync attempt missed — will retry",131  error: "Sync problem",132  idle: "Not syncing",133};134135/** Colour alone is never the signal — this is the same claim in words. */136export function syncIndicatorLabel(137  indicator: SyncIndicator,138  s: SyncOutcomeSummary,139): string {140  return `${INDICATOR_LABEL[indicator]} — ${describeSyncOutcome(s)}`;141}142143const COPY: Record<SyncOutcome, string> = {144  completed_changes: "Synced",145  completed_no_changes: "Up to date — nothing to send or receive",146  no_paired_devices: "No devices paired yet",147  no_eligible_host: "Paired, but no device here can accept a sync",148  waiting_for_peer: "Waiting for the paired device to connect",149  runner_unavailable: "Sync isn't running in the background — reload Peach",150  sync_disabled: "Sync is switched off",151  private_relay_unset: "Private relay chosen but no address set",152  vault_locked: "Vault was locked — unlock and sync again",153  relay_unreachable: "Couldn't reach the relay",154  handshake_failed: "Reached the relay, but the device didn't answer",155  vault_id_mismatch: "That device holds a different vault",156  peer_protocol_outdated: "Update Peach on both devices, then re-pair them",157  reset_required:158    "Sync is reset on this device — update Peach everywhere, then re-pair each device",159  sync_generation_unknown: "This device's sync generation is unreadable — sync is stopped",160  sync_timeout: "The device stopped responding mid-sync",161  connection_lost: "The connection dropped mid-sync — will retry",162  trust_data_corrupted: "Device trust data is damaged — re-pair your devices after updating",163  run_failed: "Sync failed",164};165166/**167 * One plain-English line for a sync outcome. `detail` — the raw internal code168 * or the device inventory behind `no_eligible_host` — is appended rather than169 * hidden: it is the whole diagnostic on a real device, where devtools is not170 * an option.171 */172export function describeSyncOutcome(s: SyncOutcomeSummary): string {173  if (!s.outcome) return "Not synced yet";174  const base = COPY[s.outcome] ?? "Sync failed";175  if (s.outcome === "completed_changes") {176    const moved = [177      (s.entriesSent ?? 0) > 0 ? `${s.entriesSent} sent` : null,178      (s.entriesReceived ?? 0) > 0 ? `${s.entriesReceived} received` : null,179    ].filter(Boolean);180    return moved.length > 0 ? `${base} — ${moved.join(", ")}` : base;181  }182  return s.detail ? `${base} (${s.detail})` : base;183}