// Change-application planning. PURE: no network, no storage, no store access. // // That purity is the single highest-leverage constraint in the design, because // `plan(page, presentIds) -> what to fetch` is assertable as plain data. It is // what turns a failure-mode table into a test suite rather than a promise. import type { ChangesState } from './states'; export interface ChangesPage { oldState: ChangesState; newState: ChangesState; hasMoreChanges: boolean; created: string[]; updated: string[]; destroyed: string[]; /** `Mailbox/changes` only. `null`/absent means "assume everything changed". */ updatedProperties?: string[] | null; } /** * Collapses the overlap RFC 8620 permits, BEFORE anything iterates - so * downstream code cannot get the order wrong by following the server's array * order. */ export function normalisePage(page: ChangesPage): { created: string[]; updated: string[]; destroyed: string[]; } { const destroyed = [...new Set(page.destroyed)]; const destroyedSet = new Set(destroyed); // An id in `destroyed` wins outright: fetching it would be wasted and the // result would be `notFound`. const created = [...new Set(page.created)].filter((id) => !destroyedSet.has(id)); const createdSet = new Set(created); // An id in both `created` and `updated` is a CREATE - the create path fetches // the full envelope tier, which already includes the updated values. const updated = [...new Set(page.updated)].filter( (id) => !destroyedSet.has(id) && !createdSet.has(id), ); return { created, updated, destroyed }; } /** * An empty page STILL ADVANCES THE CURSOR. Skipping it re-requests the same * position forever. */ export function pageIsEmpty(page: ChangesPage): boolean { return page.created.length === 0 && page.updated.length === 0 && page.destroyed.length === 0; } export interface EmailFetchPlan { /** Full envelope tier. */ createIds: string[]; /** THREE properties only: id, keywords, mailboxIds. Never bodies. */ updateIds: string[]; destroyIds: string[]; } /** * `keywords` and `mailboxIds` are the ONLY mutable Email properties * (RFC 8621 s4.1). Body structure, body values, attachments, headers, * `receivedAt`, `size`, `threadId`, `preview`, `subject`, addresses and * `hasAttachment` are all immutable for the lifetime of the id. * * So an `updated` Email cannot have a changed body, and re-fetching one is pure * waste. This is also what stops a message cached while unread from staying * unread forever. * * An `updated` id we do NOT hold locally is an UNCONDITIONAL NO-OP, filtered out * BEFORE the fetch is issued. Cheaper, and it avoids having to fabricate a * `receivedAt` that a 3-property response cannot supply and the schema's NOT NULL * would reject. Safe to ignore because absence is always either "retention * decided against it" or "coverage has not reached it yet" - and coverage * enumerates CURRENT state, so it will pick the record up with the updated values * anyway. Nothing needs the update replayed. */ export function planEmailFetches( page: ChangesPage, presentIds: ReadonlySet, ): EmailFetchPlan { const { created, updated, destroyed } = normalisePage(page); return { createIds: created, updateIds: updated.filter((id) => presentIds.has(id)), destroyIds: destroyed, }; } const COUNT_PROPERTIES = new Set([ 'totalEmails', 'unreadEmails', 'totalThreads', 'unreadThreads', ]); /** * True when a `Mailbox/changes` update touched only the four counters, so a * four-integer patch is enough instead of re-fetching every folder object. * * `updatedProperties: null` means the server will not say, so everything must be * re-fetched. An EMPTY array means "nothing but the state token moved", which is * counts-only vacuously. */ export function updatedPropertiesAreCountsOnly( updatedProperties: readonly string[] | null | undefined, ): boolean { if (!updatedProperties) return false; if (updatedProperties.length === 0) return true; return updatedProperties.every((p) => COUNT_PROPERTIES.has(p)); } export interface MailboxFetchPlan { /** Needs the whole object. */ fullIds: string[]; /** Only the count columns move. */ countOnlyIds: string[]; destroyIds: string[]; } export function planMailboxFetches(page: ChangesPage): MailboxFetchPlan { const { created, updated, destroyed } = normalisePage(page); const countsOnly = updatedPropertiesAreCountsOnly(page.updatedProperties); return { fullIds: countsOnly ? created : [...created, ...updated], countOnlyIds: countsOnly ? updated : [], destroyIds: destroyed, }; } /** * Keyset-walk progress test. * * `after` is INCLUSIVE - this is specified, not implementation-defined. * RFC 8621 s4.4.1: the `receivedAt` of the Email "must be the same or after this * date-time to match the condition". So every page after the first re-returns the * boundary message(s); dedupe by id on commit makes that free. But forward * progress therefore requires `max(receivedAt)` STRICTLY GREATER than the cursor. * * Treating `after` as exclusive and adding a millisecond, as an earlier revision * of the mobile design did, silently skips every message sharing the boundary * millisecond on any conforming server. */ export function madeForwardProgress( maxReceivedAt: string | null, scanCursor: string | null, ): boolean { if (maxReceivedAt === null) return false; if (scanCursor === null) return true; return maxReceivedAt > scanCursor; } /** Advance a scan cursor by exactly one millisecond. The last-resort paging rung. */ export function advanceOneMs(iso: string): string { const t = Date.parse(iso); if (!Number.isFinite(t)) return iso; return new Date(t + 1).toISOString(); }