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