mergeWatermark.test.ts7.6 KBView on GitHub
import {
  compareItemsDesc,
  mergeChannelFeeds,
  mergedFeedHasMore,
  type ChannelFeedSlice,
} from '@zero/server/inbox/merge';
import type { InboxChannel, InboxItem } from '@zero/server/inbox/types';

/**
 * The watermark — the one thing that makes a client-composed feed safe (inbox-triage.md
 * Phase 8).
 *
 * Four independently-paged queries cannot simply be concatenated. Slack's `fetchNextPage`
 * returns rows that are older than Slack's tail but NEWER than half of what email has already
 * drawn, so a naive concat injects them into the middle of a list the rep has already scrolled
 * past. The merge therefore renders only down to the newest per-channel tail and HOLDS the
 * rest: everything above that line is provably stable against every future page.
 *
 * These pin the four properties the list depends on — the order is a plain sort, the held rows
 * never render, paging only ever appends, and a channel with nothing to say does not stall the
 * feed for the ones that do.
 */

const item = (channel: InboxChannel, id: string, sortedAt: string): InboxItem =>
  ({
    id,
    channel,
    sortedAt,
    counterpart: { name: id },
    snippet: '',
    unread: false,
    starred: false,
    hasDraft: false,
    done: false,
    labelIds: [],
    participantCount: 1,
    ref:
      channel === 'email'
        ? { kind: 'email', threadId: id, connectionId: 'conn-1' }
        : { kind: 'slack', conversationId: '', slackChannelId: id, workspaceId: 'W1' },
  }) as unknown as InboxItem;

/** A window with more pages behind it — the only kind that can set a watermark. */
const open = (items: InboxItem[]): ChannelFeedSlice => ({ items, settled: true, exhausted: false });
/** A window with nothing left to fetch. It bounds nothing. */
const closed = (items: InboxItem[]): ChannelFeedSlice => ({
  items,
  settled: true,
  exhausted: true,
});

const ids = (items: InboxItem[]) => items.map((i) => i.id);

describe('the merged feed is a plain sort of what it renders', () => {
  it('matches sorting the union of every loaded window', () => {
    const email = [
      item('email', 'email:a', '2026-08-30T10:00:00.000Z'),
      item('email', 'email:b', '2026-08-30T08:00:00.000Z'),
    ];
    const slack = [item('slack', 'slack:1', '2026-08-30T09:00:00.000Z')];
    const linkedin = [item('linkedin', 'li:1', '2026-08-30T07:00:00.000Z')];

    const merged = mergeChannelFeeds({
      email: closed(email),
      slack: closed(slack),
      linkedin: closed(linkedin),
    });

    expect(ids(merged.items)).toEqual(ids([...email, ...slack, ...linkedin].sort(compareItemsDesc)));
    // Everything is exhausted, so nothing bounds anything: no watermark, nothing held.
    expect(merged.watermark).toBeNull();
    expect(merged.held).toBe(0);
    expect(merged.gating).toEqual([]);
  });
});

describe('nothing below the watermark renders', () => {
  const email = open([
    item('email', 'email:a', '2026-08-30T10:00:00.000Z'),
    item('email', 'email:b', '2026-08-30T09:00:00.000Z'),
    item('email', 'email:c', '2026-08-30T08:00:00.000Z'),
  ]);
  const slack = open([
    item('slack', 'slack:1', '2026-08-30T09:30:00.000Z'),
    item('slack', 'slack:2', '2026-08-30T07:00:00.000Z'),
  ]);

  it('cuts at the NEWEST tail, which is the only line email cannot cross', () => {
    const merged = mergeChannelFeeds({ email, slack });

    // Email's tail (08:00) is newer than Slack's (07:00), so email is what could still
    // deliver a row above 08:00 — and `slack:2` at 07:00 is not safe to draw until it does.
    expect(ids(merged.items)).toEqual(['email:a', 'slack:1', 'email:b', 'email:c']);
    expect(merged.watermark).toBe('2026-08-30T08:00:00.000Z|email:c');
    expect(merged.held).toBe(1);
    expect(merged.gating).toEqual(['email']);
  });

  it('reports more to show while a row is held, so the list keeps its Load more', () => {
    const merged = mergeChannelFeeds({ email, slack });
    expect(mergedFeedHasMore({ email, slack }, merged)).toBe(true);
  });

  it('breaks a same-second tie on id, so exactly one channel gates', () => {
    // Two tails on the same timestamp is the ordinary case for a busy minute. `sortedAt`
    // alone cannot separate them, so the comparator falls through to id — and the boundary
    // stays a single row, which is what keeps `fetchNextPage` a one-channel request instead
    // of a fan-out that would page a channel whose rows are already renderable.
    const linkedin = open([item('linkedin', 'li:1', '2026-08-30T08:00:00.000Z')]);
    const merged = mergeChannelFeeds({ email, linkedin });

    expect(merged.gating).toEqual(['linkedin']);
    expect(merged.watermark).toBe('2026-08-30T08:00:00.000Z|li:1');
    // `email:c` shares the timestamp but sorts below the boundary, so it waits too — held,
    // not dropped, and it renders the moment LinkedIn pages past it.
    expect(ids(merged.items)).toEqual(['email:a', 'email:b', 'li:1']);
  });
});

describe('paging one channel only ever extends the list', () => {
  it('never reorders or duplicates a row already on screen', () => {
    const emailPage1 = [
      item('email', 'email:a', '2026-08-30T10:00:00.000Z'),
      item('email', 'email:b', '2026-08-30T08:00:00.000Z'),
    ];
    const slack = open([
      item('slack', 'slack:1', '2026-08-30T09:00:00.000Z'),
      item('slack', 'slack:2', '2026-08-30T06:00:00.000Z'),
    ]);

    const before = mergeChannelFeeds({ email: open(emailPage1), slack });
    expect(ids(before.items)).toEqual(['email:a', 'slack:1', 'email:b']);

    // Email pages back — the gating channel, so this is exactly what `fetchNextPage` asks for.
    // The new rows are older than email's tail by construction: that is the invariant the
    // watermark is derived from.
    const emailPage2 = [
      ...emailPage1,
      item('email', 'email:c', '2026-08-30T07:00:00.000Z'),
      item('email', 'email:d', '2026-08-30T05:00:00.000Z'),
    ];
    const after = mergeChannelFeeds({ email: open(emailPage2), slack });

    expect(ids(after.items).slice(0, before.items.length)).toEqual(ids(before.items));
    // The held Slack row appears in its true position now that email has proven nothing can
    // land above it — appended below the rows already drawn, never spliced between them.
    expect(ids(after.items)).toEqual(['email:a', 'slack:1', 'email:b', 'email:c', 'slack:2']);
    expect(new Set(ids(after.items)).size).toBe(after.items.length);
  });
});

describe('a channel with nothing to say does not stall the feed', () => {
  it('bounds nothing when it came back empty', () => {
    const email = open([
      item('email', 'email:a', '2026-08-30T10:00:00.000Z'),
      item('email', 'email:b', '2026-08-30T09:00:00.000Z'),
    ]);
    // Settled, holding nothing. These sources return an empty page only at the end, so there
    // is nothing to come — a tail of "negative infinity" here would hold the whole feed back.
    const whatsapp: ChannelFeedSlice = { items: [], settled: true, exhausted: false };

    const merged = mergeChannelFeeds({ email, whatsapp });

    expect(ids(merged.items)).toEqual(['email:a', 'email:b']);
    expect(merged.gating).toEqual(['email']);
    expect(merged.held).toBe(0);
  });

  it('renders nothing at all while a channel is still resolving its first page', () => {
    const merged = mergeChannelFeeds({
      email: closed([item('email', 'email:a', '2026-08-30T10:00:00.000Z')]),
      slack: { items: [], settled: false, exhausted: false },
    });

    // An unsettled channel could return an item of ANY timestamp, so no prefix is provably
    // stable — rendering early is how a row appears at the top and then jumps.
    expect(merged.items).toEqual([]);
    expect(merged.ready).toBe(false);
  });
});