// Session resolution, store lifecycle and single-flight. The thin layer every // `/api/offline/*` replica route goes through. import { logger } from '@/lib/logger'; import { fetchJmapSession, accountIdFor, CAP_MAIL, type JmapSessionInfo } from '@/lib/mail-index/jmap'; import { withIndexKey } from '@/lib/mail-index/key'; import { getStoreDir } from '@/lib/mail-index/paths'; import { IndexSessionError, resolveIndexSession, type IndexSession, } from '@/lib/mail-index/reindex'; import { classify, ReplicaSyncError } from './errors'; import { ReplicaStore, type RetentionPolicy } from './store'; import { BUDGET, runCycle, type CycleReport } from './sync'; export { IndexSessionError, resolveIndexSession }; export type { IndexSession }; /** * Opens the replica for one operation and closes it afterwards. * * The key is fetched from the main process over the inherited fd for the duration * of the call only and zeroed after (`withIndexKey`) - there is no cached handle * and no resident key. A keychain round trip costs microseconds against work that * makes network calls. */ export async function withReplica( accountId: string, fn: (store: ReplicaStore) => Promise | T, ): Promise { const storeDir = getStoreDir(); if (!storeDir) { throw new IndexSessionError('The offline replica is not enabled in this deployment.', 404); } return withIndexKey(accountId, async (key) => { const store = ReplicaStore.open({ storeDir, accountId, key }); try { return await fn(store); } finally { store.close(); } }); } /** * The JMAP account whose mail is replicated. * * v1 replicates the PRIMARY mail account only. Every primary key already carries * `jmap_account_id`, so adding the delegated/shared accounts a single login also * exposes is inserting rows rather than a migration - JMAP ids are unique only * WITHIN an account, and a schema that merged them would be cross-account leakage * that costs nothing to prevent today and is unfixable later. */ export function primaryMailAccountId(session: JmapSessionInfo): string | null { return accountIdFor(session, CAP_MAIL); } /** * Single-flight per local account. * * On `globalThis` rather than in module scope for the same reason * `lib/mail-index/key.ts` keeps its channel there: Next re-evaluates route * modules (dev HMR, and separate module instances across route bundles), so a * module-scoped map is not once-per-process and two overlapping requests would * each get their own "single" flight. A Symbol key on globalThis is the one place * in a Node process that survives module re-evaluation. */ const FLIGHT_KEY = Symbol.for('vncmail.offlineReplica.inFlight'); function flights(): Map> { const holder = globalThis as unknown as Record> | undefined>; const existing = holder[FLIGHT_KEY]; if (existing) return existing; const created = new Map>(); holder[FLIGHT_KEY] = created; return created; } export interface SyncOptions { /** Overrides the persisted policy for this cycle, and persists the override. */ policy?: RetentionPolicy; /** Forces a rebuild: sets the sticky resync flag before the cycle runs. */ forceResync?: boolean; } /** * Runs one cycle for the calling session's account, coalescing concurrent callers * onto the same promise. * * Coalescing rather than aborting is deliberate: an implementation that set an * abort flag and returned produced a cancelled sync and no new one - a "Sync now" * tap during a sync did nothing at all. The in-flight promise is assigned to the * map BEFORE the cycle body runs, because several early-return paths resolve * synchronously and a later assignment leaves a re-entrancy hole; the cleanup is * identity-checked so a slow loser cannot delete a newer flight. */ export async function syncAccount( indexSession: IndexSession, options: SyncOptions = {}, ): Promise { const map = flights(); const existing = map.get(indexSession.accountId); if (existing) return existing; const run = (async (): Promise => { // The session fetch is the FIRST network call of a cycle, so when the backend // is unreachable this is where it fails - and it must be classified by the same // taxonomy as everything else. Found by execution: without this, an offline // sync surfaced a bare `JmapIndexError` 502 with no error class, so a caller // could not tell "the network is down, retry later" from "this deployment is // broken". "Offline is not an error" has to hold at the very first call too. let session: JmapSessionInfo; try { session = await fetchJmapSession(indexSession.serverUrl, indexSession.authHeader); } catch (error) { const status = (error as { status?: number } | null)?.status; const message = error instanceof Error ? error.message : String(error); // `JmapIndexError.status` is OUR OWN value, not a server response status: // it is 401 for auth, 429 for rate limiting, 504 for a timeout, and 502 for // everything else - INCLUDING a `fetch` rejection with no server involved at // all. So a bare 502 must be classified from the message, or "the machine is // offline" is misread as "the server returned a 5xx". Passing the synthetic // status straight into `classify` produced exactly that, found by the // network-cut integration run. if (status === 401 || status === 403) throw error; const cls = status === 429 ? 'RateLimit' as const : status === 504 ? 'Transport' as const : classify({ message }); throw new ReplicaSyncError(cls, message); } const jmapAccountId = primaryMailAccountId(session); if (!jmapAccountId) { throw new IndexSessionError('This account has no JMAP mail capability.', 409); } return withReplica(indexSession.accountId, async (store) => { const now = Date.now(); if (options.policy) store.transaction(() => { store.setPolicy(options.policy as RetentionPolicy); }); if (options.forceResync) { store.transaction(() => { store.patchFlags(now, { resyncRequired: true }); }); } const policy = store.getPolicy(); const report = await runCycle({ store, session, authHeader: indexSession.authHeader, jmapAccountId, policy, now, deadline: now + BUDGET.wallClockMs, }); logger.info('offline-replica: cycle complete', { slot: indexSession.slot, ok: report.ok, phase: report.coveragePhase, envelopes: report.envelopesWritten, bodies: report.bodiesWritten, deleted: report.envelopesDeleted, unfinished: report.unfinishedWork, warnings: report.warnings.length, durationMs: report.durationMs, }); return report; }); })(); map.set(indexSession.accountId, run); try { return await run; } finally { if (map.get(indexSession.accountId) === run) map.delete(indexSession.accountId); } } /** * Resolves the JMAP account id for a READ without any network call. * * The read path must work with the backend unreachable, so it cannot fetch a JMAP * session to learn the primary account id - that fetch is exactly what fails when * offline. The store knows which account ids it holds rows for; with one * replicated account that is unambiguous, and the caller may also pass an explicit * id. */ export function resolveReadAccountId(store: ReplicaStore, requested?: string | null): string | null { const known = store.knownJmapAccountIds(); if (requested && known.includes(requested)) return requested; if (known.length === 1) return known[0]; if (requested) return null; return known[0] ?? null; }