diff --git a/db/queries.ts b/db/queries.ts index 92074a1..32a4918 100644 --- a/db/queries.ts +++ b/db/queries.ts @@ -1,4 +1,4 @@ -import { eq, and } from 'drizzle-orm'; +import { eq, and, inArray } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; import { emailAccounts } from './schema'; @@ -71,6 +71,24 @@ export async function getAllSyncedAccounts(): Promise { return rows.map(open); } +/** + * Clear a `syncing` or `queued` status left behind by a process that died mid-sync. + * + * `getAllSyncedAccounts` only picks up `status = 'synced'`, so an account stranded at either — the + * resync route sets `syncing` before awaiting and only clears it after; a first sync sets `queued` + * before handing off to the in-process job runner — silently drops out of both the cron and the IDLE + * watcher forever. A fresh process can't have either genuinely in flight (the job runner is in-memory, + * not persisted), so on startup that state only ever means "orphaned by the last restart." `performResync` + * — what the cron and IDLE watcher call — already handles an account with no prior sync state by doing a + * full fetch, so resetting to `synced` rather than replaying the dedicated first-sync job is safe. + */ +export async function resetStuckSyncingAccounts(): Promise { + await db + .update(emailAccounts) + .set({ status: 'synced' }) + .where(inArray(emailAccounts.status, ['syncing', 'queued'])); +} + /** Replace an account's stored secret — used when an OAuth token is refreshed. */ export async function updateEmailAccountCredentials(id: number, credentials: Record): Promise { await db.update(emailAccounts).set({ credentials: seal(credentials) }).where(eq(emailAccounts.id, id)); diff --git a/sidecar/email-cron.ts b/sidecar/email-cron.ts index b20f6b6..c933e1b 100644 --- a/sidecar/email-cron.ts +++ b/sidecar/email-cron.ts @@ -1,5 +1,5 @@ import { getUserById } from 'officerdb'; -import { getAllSyncedAccounts } from '../db/queries'; +import { getAllSyncedAccounts, resetStuckSyncingAccounts } from '../db/queries'; import { performResync } from './resync'; const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes @@ -32,6 +32,9 @@ async function tick() { export function initEmailCron() { if (timer) return; + resetStuckSyncingAccounts().catch((err) => { + console.error('[email-cron] Failed to reset stuck accounts:', err instanceof Error ? err.message : err); + }); console.log(`[email-cron] Starting email sync cron (every ${INTERVAL_MS / 60_000} min)`); timer = setInterval(tick, INTERVAL_MS); setTimeout(tick, 30_000);