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.
      }
    }
  });
}