sync-alarm.ts
1// sync-alarm.ts — the 10-minute background sync tick.2//3// Five triggers, in order of reliability:4// 1. this alarm (survives MV3 service-worker death — only path that does)5// 2. SW activation hook in entry.ts (startup reason — first dial without6// waiting for the alarm)7// 3. device_list_changed on pairing/unpair (paired-devices.ts)8// 4. vault_change on every committed write (vault.ts fireSyncTrigger)9// 5. post-adoption one-shot (schedulePostAdoptionSync) — bridges the gap10// between a failed inline snapshot and the next 10-minute tick11//12// All four call the same `triggerSyncIfReachable`. The in-flight guard there13// ensures a slow sync is never preempted by an alarm firing while it runs.1415import { triggerSyncIfReachable } from "./sync-trigger";1617/**18 * Single alarm name. Shared with the listener below — change together.19 * The `peach-` prefix keeps this distinct from any third-party alarm a20 * future extension may install.21 */22export const SYNC_ALARM_NAME = "peach-sync-periodic";2324/**25 * Chrome's recurring-alarm floor is 1 minute — anything shorter is treated26 * as a one-shot. 10 is well above that and the longest the user is likely27 * to wait for changes to propagate between paired devices without manual28 * action. Lowering this (e.g. for testing) must keep it >= 1.29 */30export const SYNC_ALARM_PERIOD_MINUTES = 10;3132/**33 * One-shot alarm prefix for post-adoption sync retries. Unlike the periodic34 * alarm, these are fire-once: Chrome clears them after they fire. The prefix35 * lets the listener match any retry without a fixed name.36 */37const POST_ADOPTION_ALARM_PREFIX = "peach-post-adoption-";3839let alarmListenerInstalled = false;4041/**42 * Schedule the recurring sync alarm. Idempotent — calling twice does not43 * stack two alarms. Uses `periodInMinutes` (Chrome's own repeat primitive)44 * rather than a manual reschedule loop, so a worker kill between fires does45 * not interrupt the schedule — Chrome holds the next-fire time.46 */47export function setupSyncAlarm(): void {48 if (!chrome.alarms?.create || !chrome.alarms?.clear) return;49 if (!alarmListenerInstalled) {50 // Static, not `await import()`: runtime import() throws in an MV3 service51 // worker (scripts/check-sw-static-imports.mjs pins this). triggerSyncIfReachable52 // is top-level-imported via sync-trigger, which itself statically imports53 // its deps — no cycle, no dynamic import.54 chrome.alarms.onAlarm.addListener((alarm) => {55 if (alarm.name === SYNC_ALARM_NAME) {56 void triggerSyncIfReachable({ reason: "alarm" }).catch(() => undefined);57 } else if (alarm.name.startsWith(POST_ADOPTION_ALARM_PREFIX)) {58 void triggerSyncIfReachable({ reason: "alarm", force: true }).catch(() => undefined);59 }60 });61 alarmListenerInstalled = true;62 }63 // Clear-then-create so a changed period (config, future toggle) takes effect64 // without leaving a stale one-shot behind.65 chrome.alarms.clear(SYNC_ALARM_NAME);66 chrome.alarms.create(SYNC_ALARM_NAME, {67 periodInMinutes: SYNC_ALARM_PERIOD_MINUTES,68 });69}7071/**72 * Test seam. Resets the one-shot listener-install flag so a fresh73 * `setupSyncAlarm()` call re-registers against the (new) chrome mock.74 */75export function _resetSyncAlarmForTest(): void {76 alarmListenerInstalled = false;77}7879/**80 * Schedule one-shot sync retries after a failed inline snapshot. Uses81 * chrome.alarms (survives SW death) instead of setTimeout (does not).82 * Two retries: 30s and 2min. The 10-minute periodic alarm is the long-term83 * backstop. Chrome's minimum alarm resolution is ~30s in stable.84 */85export function schedulePostAdoptionSync(): void {86 if (!chrome.alarms?.create) return;87 const now = Date.now();88 chrome.alarms.create(`${POST_ADOPTION_ALARM_PREFIX}${now}-1`, {89 delayInMinutes: 0.5,90 });91 chrome.alarms.create(`${POST_ADOPTION_ALARM_PREFIX}${now}-2`, {92 delayInMinutes: 2,93 });94}