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>
104 lines
4.6 KiB
TypeScript
104 lines
4.6 KiB
TypeScript
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);
|
|
});
|
|
});
|