archived-thread-guard.ts7.1 KBView on GitHub /**
* Archived-thread guard — keeps a just-archived thread out of the inbox list no
* matter which write tries to put it back.
*
* Replaces the passive detector this module used to be. The history is in
* apps/server/src/docs/bug-mark-done-threads-reappear.md; the short version:
*
* Round 1 treated the optimistic removal as an edit and patched the one window
* it could name — a `listThreads` response issued inside the archive round-trip
* — by re-applying the removal once, the instant the mutation settled. That
* assumed a 227-380 ms round-trip. Staging measures `mail.markDone` at up to
* 7381 ms, and all three resurrections prod ever recorded landed 15.7-18.2 s
* after the click, every one a `fetch-response` — i.e. seconds AFTER the
* one-shot repair had already run, with nothing left to oppose them.
*
* So the removal is made an invariant instead of an edit. While a thread is
* guarded, any write to a `mail.listThreads` query that reintroduces it is
* repaired immediately. That covers the late fetch response, an IndexedDB
* restore from `restoreQueries`, a side-inbox prefetch and `fetchNextPage`
* alike — we no longer have to know which one is guilty.
*
* The guard is deliberately time-boxed rather than clever: after
* `GUARD_TTL_MS` the server is authoritative again. A thread that legitimately
* returns to the inbox does so through `markActive` / undo / a mutation
* rollback, and every one of those calls `releaseArchivedThreads` first.
*/
import type { QueryClient } from '@tanstack/react-query';
/**
* How long a thread stays guarded after its archive is issued.
*
* Must comfortably exceed the worst observed resurrection (18.2 s) and the
* worst observed `mail.markDone` round-trip (7.4 s), while staying short enough
* that a genuine new reply re-adding INBOX is not hidden for long.
*/
const GUARD_TTL_MS = 60_000;
type Guarded = { archivedAt: number; reported: boolean };
const guarded = new Map<string, Guarded>();
/** Set by the host app so suppressions are reported somewhere we can read them. */
let reportSuppression: SuppressionReporter | null = null;
export type SuppressionEvent = {
threadId: string;
/** `fetch-response`, `set-query-data`, or the raw React Query action type. */
source: string;
msSinceArchive: number;
queryKey=[redacted];
};
export type SuppressionReporter = (event: SuppressionEvent) => void;
/**
* Where suppressions get reported. Deliberately injected rather than importing
* a client here: the whole reason Round 1 could not be verified is that its
* diagnostic went to PostHog only, and the reporting user's browser sends
* PostHog nothing — 66 archives on staging produced zero `email_marked_done`.
*/
export function setSuppressionReporter(reporter: SuppressionReporter | null): void {
reportSuppression = reporter;
}
function prune(now: number): void {
for (const [threadId, entry] of guarded) {
if (now - entry.archivedAt > GUARD_TTL_MS) guarded.delete(threadId);
}
}
/** Called by `optimisticMarkDone` right after the optimistic removal. */
export function guardArchivedThreads(threadIds: string[]): void {
const now = Date.now();
prune(now);
for (const threadId of threadIds) {
guarded.set(threadId, { archivedAt: now, reported: false });
}
}
/**
* Stop guarding — the thread is legitimately back in the inbox (mutation
* rollback, undo, "move to undone"). Without this the guard would fight every
* rollback and the thread could never come back.
*/
export function releaseArchivedThreads(threadIds: string[]): void {
for (const threadId of threadIds) guarded.delete(threadId);
}
/** Test seam. */
export function __resetArchivedThreadGuard(): void {
guarded.clear();
reportSuppression = null;
}
type MaybeInfiniteList = {
pages?: Array<{ threads?: Array<{ id?: string }> } | null>;
pageParams?: unknown[];
};
/**
* tRPC nests the route: `[['mail','listThreads'], {input, type}]`. Test
* harnesses (and any hand-rolled key) use the flat `['mail','listThreads', …]`
* form. Accept both — a guard that silently matches nothing is worse than no
* guard, because it looks installed.
*/
function isListThreadsKey(queryKey=[redacted] unknown[]): boolean {
const head = queryKey[0];
if (Array.isArray(head)) return head[0] === 'mail' && head[1] === 'listThreads';
return head === 'mail' && queryKey[1] === 'listThreads';
}
/** Guarded ids present in this query's data. */
function guardedIdsIn(data: unknown): string[] {
if (guarded.size === 0) return [];
const pages = (data as MaybeInfiniteList | undefined)?.pages;
if (!Array.isArray(pages)) return [];
const found = new Set<string>();
for (const page of pages) {
for (const thread of page?.threads ?? []) {
if (thread?.id && guarded.has(thread.id)) found.add(thread.id);
}
}
return Array.from(found);
}
function stripGuarded(data: unknown, ids: Set<string>): unknown {
const list = data as MaybeInfiniteList;
const pages = list?.pages;
if (!Array.isArray(pages)) return data;
return {
...list,
pages: pages.map((page) =>
page ? { ...page, threads: (page.threads ?? []).filter((t) => !t?.id || !ids.has(t.id)) } : page,
),
};
}
function describeSource(action: { type?: string; manual?: boolean } | undefined): string {
if (!action?.type) return 'unknown';
if (action.type === 'success') return action.manual ? 'set-query-data' : 'fetch-response';
return action.type;
}
/**
* Subscribe to the query cache and hold the invariant. Returns an unsubscribe
* function. Installed alongside the persistence subscription in
* `query-provider.tsx`.
*
* The repair writes through `setQueryData`, which raises another `updated`
* event — that one carries no guarded ids, so it strips nothing and the loop
* terminates after a single extra pass.
*/
export function installArchivedThreadGuard(queryClient: QueryClient): () => void {
return queryClient.getQueryCache().subscribe((event) => {
if (event.type !== 'updated') return;
if (guarded.size === 0) return;
if (!isListThreadsKey(event.query.queryKey)) return;
const now = Date.now();
prune(now);
const resurrected = guardedIdsIn(event.query.state.data);
if (resurrected.length === 0) return;
const ids = new Set(resurrected);
queryClient.setQueryData(event.query.queryKey, (old: unknown) => stripGuarded(old, ids));
const source = describeSource((event as { action?: { type?: string; manual?: boolean } }).action);
for (const threadId of resurrected) {
const entry = guarded.get(threadId);
// One report per thread per archive: a bulk archive would otherwise emit
// the same finding once per page write.
if (!entry || entry.reported) continue;
entry.reported = true;
const payload: SuppressionEvent = {
threadId,
source,
msSinceArchive: now - entry.archivedAt,
queryKey=[redacted],
};
console.warn('[markDone] suppressed an archived thread returning to the inbox list', payload);
try {
reportSuppression?.(payload);
} catch {
// Telemetry must never break the list.
}
}
});
}