email: keep an account's sync position inside its own emails.db

The messages were in emails.db and the position — last_sync_at, and per-folder uidvalidity/lastuid —
was a jsonb column on email_accounts in Postgres. Two stores for one fact, with an edge that only shows
up when you try to move a mailbox to another machine.

The expensive part of an email account is the first sync: hours of IMAP for a large mailbox, which is
exactly why "copy emails.db to the new server" is the obvious way to bring one across. With the position
in Postgres that silently does not work — the new server's column is empty, !last_sync_at says first
sync, and the whole mailbox downloads again on top of the one just restored.

The other direction is quieter and worse. Restore an OLDER emails.db while Postgres holds a NEWER
position and the sidecar skips every message between the two, permanently, because nothing looks below
lastuid again. Re-syncing is slow; skipping mail is data loss nobody notices.

Not a new idea — the Gmail path already read SQLite and fell back to Postgres, backfilling so the
fallback was taken once. Only the IMAP path had not followed. This extracts that pattern so both use one
copy, and unifies the isFirstSync fork in accounts.ts, which is how the two drifted apart to begin with.

The file wins over Postgres, always, and only migrates when it holds nothing at all. Topping up a
partial position from Postgres would reintroduce precisely the divergence this removes.

email_accounts.sync_meta is kept and marked legacy rather than dropped: it is the one-time backfill
source for every account created before this, and dropping it would strand any that has not synced
since. Nothing writes to it now.

11 tests on the migration, aimed at both expensive failures — migrating when we should not, and failing
to migrate an account that predates the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:39:13 +00:00
co-authored by Claude Opus 5
parent 41663bc207
commit 0c90216c7a
6 changed files with 317 additions and 49 deletions
@@ -19,6 +19,15 @@ export const emailAccounts = pgTable(
enabled: boolean('enabled').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
status: text('status').notNull().default('connected'),
/**
* LEGACY, read-only. The sync position now lives in the account's own `emails.db`
* (`sidecar/email/sync-meta.ts`), so the file is self-describing and an account can be moved to
* another machine by copying it.
*
* Kept because it is the one-time backfill source for every account created before that move; the
* first sync after it migrates these keys into SQLite and nothing writes here again. Dropping it
* would strand any account that has not synced since.
*/
syncMeta: jsonb('sync_meta').notNull().default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
+15 -8
View File
@@ -12,7 +12,8 @@ import { validateImapConnection } from './imap-validate';
import { startSync, isSyncing, getSyncStates } from './sync-runner';
import { imapSyncSteps } from './imap-sync';
import { gmailSyncSteps } from './gmail-api';
import { openEmailDb, getSyncMeta } from './store';
import { openEmailDb } from './store';
import { hasSyncedBefore } from './sync-meta';
import { performResync } from './resync';
type CreateAccountBody = {
@@ -49,7 +50,11 @@ accountsRouter.get('/', async (ctx) => {
if (hasActiveAccounts) {
// In-process now: a running sync is one this process started, not a queued job row. An account marked
// syncing with nothing running is stale — the usual cause is a restart mid-sync.
activeJobAccountIds = new Set(getSyncStates().filter((s) => s.status === 'running').map((s) => s.accountId));
activeJobAccountIds = new Set(
getSyncStates()
.filter((s) => s.status === 'running')
.map((s) => s.accountId),
);
for (const a of accounts) {
if ((a.status === 'syncing' || a.status === 'queued') && !activeJobAccountIds.has(a.id)) {
@@ -133,17 +138,16 @@ accountsRouter.post('/:id/sync', async (ctx) => {
if (account.status === 'syncing') throw BAD_REQUEST('Account is already syncing');
// Determine if this is a first sync or resync
// One answer for both providers now. This used to fork: Gmail read SQLite, IMAP read the Postgres
// column, which is how the two drifted apart in the first place.
let isFirstSync = true;
if (account.provider === 'gmail' && account.authType === 'oauth') {
{
const db = openEmailDb(user.email, account.email);
try {
isFirstSync = !getSyncMeta(db, 'last_sync_at');
isFirstSync = !hasSyncedBefore(db, account.syncMeta as Record<string, unknown>);
} finally {
db.close();
}
} else {
const syncMeta = (account.syncMeta ?? {}) as Record<string, unknown>;
isFirstSync = !syncMeta.last_sync_at;
}
// Resync: call directly, no job queue
@@ -240,7 +244,10 @@ async function resolveAuth(
try {
accessToken = await getValidGoogleAccessToken(userId);
} catch (err) {
return { ok: false, error: `Token refresh failed — reconnect Google account: ${err instanceof Error ? err.message : err}` };
return {
ok: false,
error: `Token refresh failed — reconnect Google account: ${err instanceof Error ? err.message : err}`,
};
}
if (!accessToken) return { ok: false, error: 'No access token available — reconnect Google account' };
+71 -22
View File
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';
import { openEmailDb, upsertFromRawEml } from './store';
import { readSyncMeta, writeSyncMeta } from './sync-meta';
import { refreshGoogleAccessToken } from '../../api/integrations/google-auth';
import {
getEmailAccount,
@@ -7,7 +8,6 @@ import {
upsertUserIntegration,
getServerIntegration,
updateEmailAccountStatus,
updateEmailAccountSyncMeta,
getDockPaths,
setDockPaths,
} from 'officerdb';
@@ -60,7 +60,12 @@ const SPECIAL_USE_LABEL_MAP: Record<string, string> = {
const SKIP_SPECIAL_USE = new Set(['\\Trash', '\\Junk', '\\All']);
type FolderInfo = { specialUse?: string; path: string; flags: Set<string>; status?: { uidNext?: number; uidValidity?: number } };
type FolderInfo = {
specialUse?: string;
path: string;
flags: Set<string>;
status?: { uidNext?: number; uidValidity?: number };
};
function shouldSkipFolder(folder: FolderInfo): boolean {
if (folder.flags.has('\\Noselect') || folder.flags.has('\\NonExistent')) return true;
@@ -125,10 +130,14 @@ const emailSyncHandler = {
// Resolve auth (refreshes OAuth token if needed)
const imapAuth = await resolveImapAuth(meta);
// Load syncMeta from DB (always fresh, not from job meta)
// Always re-read the position rather than trusting job meta, which can be stale after a retry.
const freshAccount = await getEmailAccount(account.id);
if (!freshAccount) throw new PermanentError(`Email account ${account.id} not found`);
const syncMeta = (freshAccount.syncMeta ?? {}) as Record<string, unknown>;
// Opened here rather than further down because the position now lives inside it. Postgres is
// passed only as the one-time backfill for accounts that predate the move — see sync-meta.ts.
const db = openEmailDb(userEmail, account.email);
const syncMeta: Record<string, unknown> = readSyncMeta(db, freshAccount.syncMeta as Record<string, unknown>);
const isIncremental = !!syncMeta.last_sync_at;
await updateEmailAccountStatus(account.id, 'syncing');
@@ -142,8 +151,6 @@ const emailSyncHandler = {
let errors = 0;
let allDone = false;
const db = openEmailDb(userEmail, account.email);
// Load existing IDs for dedup (once, shared across reconnections)
const existingIds = new Set<string>();
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
@@ -152,8 +159,14 @@ const emailSyncHandler = {
try {
while (!allDone && reconnects <= MAX_RECONNECTS) {
if (reconnects > 0) {
console.log(`[email-sync] Reconnecting (${reconnects}/${MAX_RECONNECTS}) after ${RECONNECT_DELAY_MS / 1000}s...`);
await ctx.updateProgress({ current: saved + skipped, total: 0, label: `Reconnecting (${reconnects}/${MAX_RECONNECTS})...` });
console.log(
`[email-sync] Reconnecting (${reconnects}/${MAX_RECONNECTS}) after ${RECONNECT_DELAY_MS / 1000}s...`,
);
await ctx.updateProgress({
current: saved + skipped,
total: 0,
label: `Reconnecting (${reconnects}/${MAX_RECONNECTS})...`,
});
await new Promise((r) => setTimeout(r, RECONNECT_DELAY_MS));
// Re-read syncMeta from DB to get latest saved UIDs
@@ -184,7 +197,9 @@ const emailSyncHandler = {
await client.connect();
console.log(`[email-sync] IMAP connected${reconnects > 0 ? ` (reconnect ${reconnects})` : ''}`);
const folders = await client.list({ statusQuery: { uidNext: true, uidValidity: true } }) as FolderInfo[];
const folders = (await client.list({
statusQuery: { uidNext: true, uidValidity: true },
})) as FolderInfo[];
// Filter to folders that still need syncing
const foldersToSync: Array<{ folder: FolderInfo; lastUid: number }> = [];
@@ -198,7 +213,10 @@ const emailSyncHandler = {
const uidValidity = folder.status?.uidValidity ? String(folder.status.uidValidity) : null;
const uidNext = folder.status?.uidNext ?? 0;
let lastUid = storedUidValidity && uidValidity === storedUidValidity && storedLastUid ? parseInt(storedLastUid, 10) : 0;
let lastUid =
storedUidValidity && uidValidity === storedUidValidity && storedLastUid
? parseInt(storedLastUid, 10)
: 0;
if (storedUidValidity && uidValidity && storedUidValidity !== uidValidity) {
console.log(`[email-sync] UIDVALIDITY changed for ${folder.path} — will re-scan`);
@@ -224,7 +242,10 @@ const emailSyncHandler = {
for (let fi = 0; fi < foldersToSync.length; fi++) {
const { folder, lastUid } = foldersToSync[fi]!;
if (connState.error) { connectionLost = true; break; }
if (connState.error) {
connectionLost = true;
break;
}
console.log(`[email-sync] Syncing folder ${fi + 1}/${foldersToSync.length}: ${folder.path}`);
@@ -261,17 +282,33 @@ const emailSyncHandler = {
maxUid = Math.max(maxUid, msg.uid);
if (!msg.source) { errors++; continue; }
if (!msg.source) {
errors++;
continue;
}
const raw = msg.source.toString('utf-8');
const id = messageIdToStableId(raw);
if (!id) { errors++; continue; }
if (!id) {
errors++;
continue;
}
if (existingIds.has(id)) { skipped++; continue; }
if (existingIds.has(id)) {
skipped++;
continue;
}
const labels = [label];
try {
upsertFromRawEml({ db, id, raw, integration: account.provider, emailAccount: account.email, labels });
upsertFromRawEml({
db,
id,
raw,
integration: account.provider,
emailAccount: account.email,
labels,
});
existingIds.add(id);
saved++;
} catch {
@@ -285,7 +322,7 @@ const emailSyncHandler = {
if (maxUid > effectiveLastUid) {
syncMeta[`imap_lastuid:${folder.path}`] = String(maxUid);
await updateEmailAccountSyncMeta(account.id, syncMeta as Record<string, unknown>);
writeSyncMeta(db, syncMeta);
}
}
}
@@ -293,7 +330,11 @@ const emailSyncHandler = {
const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
if (errMsg.includes('Nothing to fetch')) {
// No messages in range — normal
} else if (errMsg.includes('not available') || errMsg.includes('timeout') || errMsg.includes('closed')) {
} else if (
errMsg.includes('not available') ||
errMsg.includes('timeout') ||
errMsg.includes('closed')
) {
console.log(`[email-sync] Connection lost during fetch in ${folder.path}: ${errMsg}`);
connectionLost = true;
} else {
@@ -308,8 +349,12 @@ const emailSyncHandler = {
}
console.log(`[email-sync] ${folder.path} done: ${saved} saved, ${skipped} skipped, ${errors} errors`);
await ctx.updateProgress({ current: saved + skipped, total: 0, label: `${folder.path}: ${saved} saved` });
await updateEmailAccountSyncMeta(account.id, syncMeta as Record<string, unknown>);
await ctx.updateProgress({
current: saved + skipped,
total: 0,
label: `${folder.path}: ${saved} saved`,
});
writeSyncMeta(db, syncMeta);
} finally {
lock.release();
}
@@ -341,14 +386,18 @@ const emailSyncHandler = {
throw new Error(`IMAP sync incomplete after ${MAX_RECONNECTS} reconnection attempts`);
}
// Mark sync complete
// Mark sync complete. Last, and only after every message above is stored: if this never runs,
// the position stays behind and those messages are fetched again. Re-fetching is wasteful;
// advancing past mail that was never stored would lose it silently.
syncMeta.last_sync_at = new Date().toISOString();
await updateEmailAccountSyncMeta(account.id, syncMeta as Record<string, unknown>);
writeSyncMeta(db, syncMeta);
} finally {
db.close();
}
console.log(`[email-sync] Done: saved ${saved}, skipped ${skipped}, errors ${errors}, reconnects ${reconnects}`);
console.log(
`[email-sync] Done: saved ${saved}, skipped ${skipped}, errors ${errors}, reconnects ${reconnects}`,
);
ctx.meta.saved = saved;
await ctx.updateProgress({ current: 1, total: 1, label: `Done — ${saved} new emails` });
},
+36 -19
View File
@@ -1,18 +1,9 @@
import { createHash } from 'node:crypto';
import {
getUserIntegration,
upsertUserIntegration,
getServerIntegration,
getEmailAccount,
updateEmailAccountSyncMeta,
} from 'officerdb';
import { getUserIntegration, upsertUserIntegration, getServerIntegration, getEmailAccount } from 'officerdb';
import { refreshGoogleAccessToken } from '@@/api/integrations/google-auth';
import { openEmailDb, getSyncMeta, setSyncMeta, upsertFromRawEml } from './store';
import {
type GmailCredentials,
loadGmailCredentials,
gmailApiSync,
} from './gmail-api';
import { readSyncMeta, writeSyncMeta } from './sync-meta';
import { type GmailCredentials, loadGmailCredentials, gmailApiSync } from './gmail-api';
export type ResyncResult = { saved: number; skipped: number; errors: number };
@@ -152,9 +143,11 @@ async function imapResync(account: ImapAccountInfo, userEmail: string): Promise<
const freshAccount = await getEmailAccount(account.id);
if (!freshAccount) throw new Error(`Email account ${account.id} not found`);
const syncMeta = (freshAccount.syncMeta ?? {}) as Record<string, unknown>;
const db = openEmailDb(userEmail, account.email);
// The position lives in this file now; the Postgres column is only a one-time backfill source for
// accounts that predate the move. See sync-meta.ts.
const syncMeta: Record<string, unknown> = readSyncMeta(db, freshAccount.syncMeta as Record<string, unknown>);
const existingIds = new Set<string>();
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
for (const row of rows) existingIds.add(row.id);
@@ -187,7 +180,8 @@ async function imapResync(account: ImapAccountInfo, userEmail: string): Promise<
const storedLastUid = syncMeta[lastUidKey] as string | undefined;
const uidValidity = folder.status?.uidValidity ? String(folder.status.uidValidity) : null;
const uidNext = folder.status?.uidNext ?? 0;
const lastUid = storedUidValidity && uidValidity === storedUidValidity && storedLastUid ? parseInt(storedLastUid, 10) : 0;
const lastUid =
storedUidValidity && uidValidity === storedUidValidity && storedLastUid ? parseInt(storedLastUid, 10) : 0;
if (lastUid > 0 && uidNext <= lastUid + 1) continue;
@@ -216,15 +210,31 @@ async function imapResync(account: ImapAccountInfo, userEmail: string): Promise<
if (msg.uid <= effectiveLastUid) continue;
maxUid = Math.max(maxUid, msg.uid);
if (!msg.source) { errors++; continue; }
if (!msg.source) {
errors++;
continue;
}
const raw = msg.source.toString('utf-8');
const id = messageIdToStableId(raw);
if (!id) { errors++; continue; }
if (existingIds.has(id)) { skipped++; continue; }
if (!id) {
errors++;
continue;
}
if (existingIds.has(id)) {
skipped++;
continue;
}
try {
upsertFromRawEml({ db, id, raw, integration: account.provider, emailAccount: account.email, labels: [label] });
upsertFromRawEml({
db,
id,
raw,
integration: account.provider,
emailAccount: account.email,
labels: [label],
});
existingIds.add(id);
saved++;
} catch {
@@ -257,7 +267,14 @@ async function imapResync(account: ImapAccountInfo, userEmail: string): Promise<
}
syncMeta.last_sync_at = new Date().toISOString();
await updateEmailAccountSyncMeta(account.id, syncMeta as Record<string, unknown>);
// Reopened deliberately: the sync loop above closes the db in its `finally`, and the position must be
// written after the messages it describes are safely stored.
const metaDb = openEmailDb(userEmail, account.email);
try {
writeSyncMeta(metaDb, syncMeta);
} finally {
metaDb.close();
}
console.log(`[resync] IMAP done: saved ${saved}, skipped ${skipped}, errors ${errors}`);
return { saved, skipped, errors };
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'bun:test';
import { Database } from 'bun:sqlite';
import { readSyncMeta, writeSyncMeta, hasSyncedBefore } from './sync-meta';
// Moving an account's sync position out of Postgres and into its own emails.db. Both failure modes are
// expensive and neither is loud, so they are pinned here:
//
// migrate when we should not -> a restored older mailbox inherits a newer position and SKIPS mail
// fail to migrate -> an existing account looks brand new and re-downloads everything
const freshDb = () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE IF NOT EXISTS sync_meta (key TEXT PRIMARY KEY, value TEXT)');
return db;
};
describe('readSyncMeta', () => {
it('reads what the file already knows', () => {
const db = freshDb();
writeSyncMeta(db, { last_sync_at: '2026-01-01T00:00:00Z', 'imap_lastuid:INBOX': '4200' });
expect(readSyncMeta(db)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_lastuid:INBOX': '4200' });
});
it('migrates from Postgres exactly once, when the file has nothing', () => {
// The account existed before the move. Without this it looks like a first sync and re-downloads
// the entire mailbox.
const db = freshDb();
const pg = { last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' };
expect(readSyncMeta(db, pg)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' });
// Backfilled, so the fallback is never consulted again.
expect(readSyncMeta(db)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' });
});
it('NEVER lets Postgres override a position the file already has', () => {
// The dangerous direction. Restore an older emails.db onto a server whose Postgres row is newer,
// and topping up from Postgres would advance past mail this file does not contain — skipped
// permanently, because nothing looks below lastuid again. The file wins, always.
const db = freshDb();
writeSyncMeta(db, { 'imap_lastuid:INBOX': '100' });
const meta = readSyncMeta(db, { 'imap_lastuid:INBOX': '9999', last_sync_at: '2026-06-01T00:00:00Z' });
expect(meta['imap_lastuid:INBOX']).toBe('100');
expect(meta.last_sync_at).toBeUndefined();
});
it('ignores non-string values rather than stringifying them', () => {
// The Postgres column is free-form jsonb. An object coerced to "[object Object]" would be a
// position that parses as a number nowhere and compares as garbage.
const db = freshDb();
expect(readSyncMeta(db, { good: 'yes', bad: { nested: true }, alsoBad: 42 } as never)).toEqual({ good: 'yes' });
});
it('is empty for a genuinely new account', () => {
expect(readSyncMeta(freshDb(), {})).toEqual({});
expect(readSyncMeta(freshDb(), null)).toEqual({});
});
});
describe('writeSyncMeta', () => {
it('upserts rather than duplicating, so a checkpoint mid-sync is safe to repeat', () => {
const db = freshDb();
writeSyncMeta(db, { 'imap_lastuid:INBOX': '10' });
writeSyncMeta(db, { 'imap_lastuid:INBOX': '20' });
expect(readSyncMeta(db)).toEqual({ 'imap_lastuid:INBOX': '20' });
});
it('skips null and undefined instead of writing them as text', () => {
// "null" as a stored lastuid would parse to NaN and compare false against every real UID.
const db = freshDb();
writeSyncMeta(db, { a: '1', b: null, c: undefined });
expect(readSyncMeta(db)).toEqual({ a: '1' });
});
it('stores numbers as their text form, since UIDs arrive both ways', () => {
const db = freshDb();
writeSyncMeta(db, { 'imap_lastuid:INBOX': 4200 });
expect(readSyncMeta(db)['imap_lastuid:INBOX']).toBe('4200');
});
});
describe('hasSyncedBefore', () => {
it('is false for a new account and true once the file records a sync', () => {
const db = freshDb();
expect(hasSyncedBefore(db)).toBe(false);
writeSyncMeta(db, { last_sync_at: '2026-01-01T00:00:00Z' });
expect(hasSyncedBefore(db)).toBe(true);
});
it('is true for an account that synced before the move but has not migrated yet', () => {
// The whole point: an unmigrated account must not be treated as new. This is the check that stands
// between an existing user and an unnecessary full re-sync of their mailbox.
expect(hasSyncedBefore(freshDb(), { last_sync_at: '2026-01-01T00:00:00Z' })).toBe(true);
});
it('is false when Postgres holds a position but no completed sync', () => {
// Folder UIDs without last_sync_at means a sync started and never finished.
expect(hasSyncedBefore(freshDb(), { 'imap_uidvalidity:INBOX': '99' })).toBe(false);
});
});
+83
View File
@@ -0,0 +1,83 @@
import type { Database } from 'bun:sqlite';
import { getSyncMeta, setSyncMeta } from './store';
// Where an account's sync position lives: IN ITS OWN emails.db, not in Postgres.
//
// ── Why this moved ──
//
// The messages are in `emails.db` and the position — `last_sync_at`, and per-folder `uidvalidity` +
// `lastuid` — used to be a jsonb column on `email_accounts` in Postgres. Two stores for one fact, and
// the split had a sharp edge that only appears when you try to move a mailbox to another machine.
//
// The expensive thing about an email account is the initial sync: hours of IMAP for a large mailbox. So
// "copy emails.db to the new server" is the obvious way to bring an account across. With the position in
// Postgres that silently does not work — the new server's `sync_meta` is empty, `!last_sync_at` says
// first sync, and it downloads the whole mailbox again on top of the one you just restored.
//
// The worse direction is quieter. Restore an OLDER emails.db while Postgres still holds a NEWER
// position, and the sidecar skips every message between the two — permanently, because nothing ever
// looks below `lastuid` again. Re-syncing is slow; skipping mail is data loss you do not notice.
//
// Keeping both in one file makes the file self-describing: it says what it holds and where it got to,
// and the two cannot disagree because they travel together.
//
// ── Why this is a generalisation, not a new idea ──
//
// The Gmail path already did exactly this (`gmail-api.ts`): read SQLite, fall back to the Postgres
// column, backfill SQLite so the fallback is only ever taken once. That was the right shape and only
// the IMAP path had not followed. This is that pattern, extracted so both use one copy of it.
//
// ── The Postgres column stays ──
//
// `email_accounts.sync_meta` is not dropped. It is the backfill source for every account that exists
// today, and it costs nothing to leave. It is no longer written to; see `readSyncMeta` for the one-way
// migration, which happens the first time an account syncs after this change.
/**
* The whole sync position for this account, from SQLite — migrating it out of Postgres once if this is
* the first read since the change.
*
* `pgFallback` is the Postgres jsonb as it stands. Pass it; the migration cannot happen without it, and
* an account whose position never migrates re-syncs its entire mailbox.
*/
export function readSyncMeta(db: Database, pgFallback?: Record<string, unknown> | null): Record<string, string> {
const rows = db.query('SELECT key, value FROM sync_meta').all() as Array<{ key: string; value: string }>;
const meta: Record<string, string> = {};
for (const row of rows) meta[row.key] = row.value;
// Only when SQLite has nothing at all. A partially-written position is still this file's own, and
// topping it up from Postgres could reintroduce exactly the divergence this move exists to prevent.
if (Object.keys(meta).length > 0) return meta;
if (pgFallback) {
for (const [key, value] of Object.entries(pgFallback)) {
if (typeof value !== 'string') continue;
meta[key] = value;
setSyncMeta(db, key, value);
}
}
return meta;
}
/**
* Persist the position. SQLite only — Postgres is no longer written.
*
* Called after the messages are already stored, which keeps the safe ordering the previous code had: if
* this never runs, the position stays behind and those messages are fetched again. Re-fetching is
* wasteful; advancing past mail that was never stored would lose it.
*/
export function writeSyncMeta(db: Database, meta: Record<string, unknown>): void {
for (const [key, value] of Object.entries(meta)) {
if (value === undefined || value === null) continue;
setSyncMeta(db, key, String(value));
}
}
/** Has this account ever completed a sync? The question `isFirstSync` is really asking. */
export function hasSyncedBefore(db: Database, pgFallback?: Record<string, unknown> | null): boolean {
if (getSyncMeta(db, 'last_sync_at')) return true;
// Not yet migrated: an account that synced before this change has its answer in Postgres, and
// treating it as a first sync would re-download the whole mailbox.
const pgValue = pgFallback?.last_sync_at;
return typeof pgValue === 'string' && pgValue.length > 0;
}