v4: what comes into a live list goes where its date puts it — a dump edited into a category stayed out of the index's feed of it until a reload, and a dump or a playlist made public went on top of the others' lists as if just posted. The index's feed now takes a dump edited into its scope, and orders the dumps that arrive by date, as the server pages them; a created playlist goes by date in the profile's and /playlists' lists, a new one first as before. Both use withByDate (utils/feed/pagedList.ts), which leaves an item older than every one loaded to a later page. The search runs again after a reconnection, for every page of dumps loaded, with its users and playlists; it shares refetchPages with usePagedList's resync. injectDump's doc said a private dump is never broadcast, while its poster gets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-10 07:43:28 +00:00
parent 308a8299f3
commit 7c00db2b84
11 changed files with 278 additions and 61 deletions

View File

@@ -210,10 +210,10 @@ export interface WSContextValue {
/** As `myVotes`, for comments. */ /** As `myVotes`, for comments. */
myLikes: Set<string>; myLikes: Set<string>;
/** /**
* Shows new dumps on the index as they are posted, above the feed `Index` * Shows new dumps on the index as they are posted, merged by date with the
* fetched. It holds, newest first and at most 100, the ones the server * feed `Index` fetched. It holds, newest first and at most 100, the ones
* announced this session, which are public, and the ones this user posted, * the server announced this session, which are public, and the ones this
* which may be private. * user posted, which may be private.
*/ */
recentDumps: Dump[]; recentDumps: Dump[];
/** /**
@@ -258,8 +258,8 @@ export interface WSContextValue {
removeCommentLike: (commentId: string, shownCount?: number) => void; removeCommentLike: (commentId: string, shownCount?: number) => void;
/** /**
* Shows a dump this user just posted on the index without waiting for its * Shows a dump this user just posted on the index without waiting for its
* broadcast, which a private dump never gets. `DumpCreateModal` calls it, * broadcast. `DumpCreateModal` calls it, and it adds the dump to
* and it adds the dump to `recentDumps`. * `recentDumps`.
*/ */
injectDump: (dump: Dump) => void; injectDump: (dump: Dump) => void;
/** /**

View File

@@ -9,11 +9,11 @@ import { useEffect, useLayoutEffect, useRef } from "react";
import type { Dump } from "../../../shared/dump.ts"; import type { Dump } from "../../../shared/dump.ts";
import { useWS, useWSEvent } from "../ws/useWS.ts"; import { useWS, useWSEvent } from "../ws/useWS.ts";
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts"; import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
import { withByDate } from "../../utils/feed/pagedList.ts";
/** /**
* Passed to {@link useDumpListSync} by the lists that need more than edits and * Passed to {@link useDumpListSync} by the lists that need more than edits and
* deletions. The index's feed adds no dump through it, since it merges * deletions.
* `WSContext`'s `recentDumps` itself.
*/ */
export interface DumpListSyncOptions { export interface DumpListSyncOptions {
/** /**
@@ -32,23 +32,11 @@ export interface DumpListSyncOptions {
* Also adds a dump `belongs` accepts that the list never held, where the * Also adds a dump `belongs` accepts that the list never held, where the
* newest-first order puts it among the dumps loaded: a new one first. One * newest-first order puts it among the dumps loaded: a new one first. One
* older than all of them is left to the next pages. For the lists sorted by * older than all of them is left to the next pages. For the lists sorted by
* `createdAt`, as a profile's dumps. * `createdAt`: the index's feed, a profile's dumps, the followed users'.
*/ */
addNew?: boolean; addNew?: boolean;
} }
/**
* Returns `list` with `dump` before the first dump posted earlier, or as is
* when none was: the dump belongs to a later page.
*/
function insertByDate(list: Dump[], dump: Dump): Dump[] {
if (list.length === 0) return [dump];
const idx = list.findIndex((d) =>
d.createdAt.getTime() < dump.createdAt.getTime()
);
return idx === -1 ? list : insertAt(list, dump, idx);
}
/** /**
* Applies the socket's dump, comment and user events to `items`, the loaded * Applies the socket's dump, comment and user events to `items`, the loaded
* list, which `setDumps` updates. A deleted dump is removed, an edited one is * list, which `setDumps` updates. A deleted dump is removed, an edited one is
@@ -96,7 +84,7 @@ export function useDumpListSync(
} }
if (!accepted) return prev; if (!accepted) return prev;
if (oldIdx !== undefined) return insertAt(prev, dump, oldIdx); if (oldIdx !== undefined) return insertAt(prev, dump, oldIdx);
return addNew ? insertByDate(prev, dump) : prev; return addNew ? withByDate(prev, dump) : prev;
}); });
}); });

View File

@@ -13,6 +13,7 @@ import {
firstPage, firstPage,
mapItems, mapItems,
type PagedList, type PagedList,
refetchPages,
restoredList, restoredList,
} from "../../utils/feed/pagedList.ts"; } from "../../utils/feed/pagedList.ts";
import type { FeedCacheEntry } from "./useFeedCache.ts"; import type { FeedCacheEntry } from "./useFeedCache.ts";
@@ -114,22 +115,8 @@ export function usePagedList<T extends { id: string }>(
const resync = useCallback(() => { const resync = useCallback(() => {
const { list, fetchPage } = latestRef.current; const { list, fetchPage } = latestRef.current;
if (!list) return; if (!list) return;
const pages = Array.from({ length: list.page }, (_, i) => i + 1); refetchPages(list.page, (page) => fetchPage(page))
Promise.all(pages.map((page) => fetchPage(page))) .then(({ items, hasMore }) => setData((l) => ({ ...l, items, hasMore })))
.then((results) => {
// An item added between two pages' fetches shifts the later page by
// one, which would list an item twice.
const byId = new Map<string, T>();
for (const item of results.flatMap((r) => r.items)) {
if (!byId.has(item.id)) byId.set(item.id, item);
}
const items = [...byId.values()];
setData((l) => ({
...l,
items,
hasMore: results[results.length - 1].hasMore,
}));
})
.catch(() => {}); .catch(() => {});
}, [setData]); }, [setData]);

View File

@@ -10,6 +10,7 @@ import { useEffect, useLayoutEffect, useRef } from "react";
import type { Playlist } from "../../../shared/playlist.ts"; import type { Playlist } from "../../../shared/playlist.ts";
import { useWS, useWSEvent } from "../ws/useWS.ts"; import { useWS, useWSEvent } from "../ws/useWS.ts";
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts"; import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
import { withByDate } from "../../utils/feed/pagedList.ts";
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */ /** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
export interface PlaylistListSyncOptions { export interface PlaylistListSyncOptions {
@@ -29,12 +30,12 @@ export interface PlaylistListSyncOptions {
} }
/** /**
* Applies the socket's playlist events to `items`, the loaded list, which * Applies the socket's playlist events to `items`, the loaded list, newest
* `setPlaylists` updates. A created playlist goes first, an updated one * first, which `setPlaylists` updates. A created playlist goes where its date
* changes in place or leaves when it turns private (unless `isOwner`), one * puts it, first when new, an updated one changes in place or leaves when it
* that left comes back where it was when it returns, a deleted one leaves, * turns private (unless `isOwner`), one that left comes back where it was
* and a playlist's `dumpCount` follows its dumps. A renamed user's playlists * when it returns, a deleted one leaves, and a playlist's `dumpCount` follows
* take their new name. * its dumps. A renamed user's playlists take their new name.
*/ */
export function usePlaylistListSync( export function usePlaylistListSync(
items: Playlist[], items: Playlist[],
@@ -73,7 +74,7 @@ export function usePlaylistListSync(
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx); if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
// An update never adds a playlist the list doesn't hold. // An update never adds a playlist the list doesn't hold.
if (ev.type === "updated" || noNewEntries) return prev; if (ev.type === "updated" || noNewEntries) return prev;
return [playlist, ...prev]; return withByDate(prev, playlist);
} }
if (ev.type === "deleted") { if (ev.type === "deleted") {

View File

@@ -55,7 +55,8 @@ export interface IndexProps {
* user only. The feed comes from * user only. The feed comes from
* `GET /api/dumps`, or `GET /api/dumps?category=`, a page at a time, * `GET /api/dumps`, or `GET /api/dumps?category=`, a page at a time,
* and the "hot", "new" and "journal" tabs share it. `WSContext` adds the dumps * and the "hot", "new" and "journal" tabs share it. `WSContext` adds the dumps
* posted since, the live vote counts, and who's online. The router keys the * posted since, or brought into the feed by an edit, by date, the live vote
* counts, and who's online. The router keys the
* page by category, so switching tabs keeps the feed and switching categories * page by category, so switching tabs keeps the feed and switching categories
* refetches it. It also opens the dump creation modal on a link shared from * refetches it. It also opens the dump creation modal on a link shared from
* Android (`?share_url=`). * Android (`?share_url=`).
@@ -139,10 +140,7 @@ export function Index(
() => dumpsState.status === "loaded" ? dumpsState.data.items : [], () => dumpsState.status === "loaded" ? dumpsState.data.items : [],
[dumpsState], [dumpsState],
); );
// Signed in, the feed holds the user's own private dumps. // A category's feed takes only the dumps filed under it.
useDumpListSync(dumps, setScopedItems, { isOwner: true });
// A category's feed takes only the new dumps filed under it.
const categoryId = category?.id; const categoryId = category?.id;
const inScope = useCallback( const inScope = useCallback(
(d: Dump) => (d: Dump) =>
@@ -152,13 +150,24 @@ export function Index(
[categorySlug, categoryId], [categorySlug, categoryId],
); );
// Signed in, the feed holds the user's own private dumps. A dump edited
// into the category, or made public, comes in where its date puts it.
useDumpListSync(dumps, setScopedItems, {
isOwner: true,
belongs: inScope,
addNew: true,
});
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]); const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
// Dumps deleted since loading are dropped, and the one the edit page just // Dumps deleted since loading are dropped, and the one the edit page just
// deleted before its `dump_deleted` arrives. // deleted before its `dump_deleted` arrives. In the server's order, by date:
// a dump made public long after it was posted isn't news, and goes below
// what came since, even when older than every dump loaded.
const combined = useMemo( const combined = useMemo(
() => () =>
[...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps] [...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps]
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId), .filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId, inScope], [recentDumps, restIds, dumps, deletedDumpIds, justDeletedId, inScope],
); );

View File

@@ -6,7 +6,7 @@
* @module * @module
*/ */
import { useCallback } from "react"; import { useCallback, useLayoutEffect, useRef } from "react";
import { Link, useSearchParams } from "react-router"; import { Link, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -39,11 +39,13 @@ import {
firstPage, firstPage,
type PagedList, type PagedList,
pageUrl, pageUrl,
refetchPages,
} from "../utils/feed/pagedList.ts"; } from "../utils/feed/pagedList.ts";
import { useFetchState } from "../hooks/shared/useFetchState.ts"; import { useFetchState } from "../hooks/shared/useFetchState.ts";
import { useLoadMore } from "../hooks/feed/usePagedList.ts"; import { useLoadMore } from "../hooks/feed/usePagedList.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts"; import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
import { useOnReconnect } from "../hooks/ws/useWS.ts";
import { import {
SEARCH_TABS, SEARCH_TABS,
searchPath, searchPath,
@@ -64,6 +66,18 @@ interface Results {
playlists: Playlist[]; playlists: Playlist[];
} }
/** Returns the first page of a search's answer, as `Search` holds it. */
function toResults(
{ dumps, users, playlists }: Wire<SearchResults>,
): Results {
return {
dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore),
dumpTotal: dumps.total ?? 0,
users: users.map(deserializePublicUser),
playlists: playlists.map(deserializePlaylist),
};
}
/** /**
* Names, for each tab a category narrows, the kind of its categories and the * Names, for each tab a category narrows, the kind of its categories and the
* parameter of `GET /api/search` that takes one. * parameter of `GET /api/search` that takes one.
@@ -80,7 +94,8 @@ const TAB_CATEGORIES: Partial<
* from `GET /api/search`, signed in when the viewer is, so they find their own * from `GET /api/search`, signed in when the viewer is, so they find their own
* private dumps and playlists too. The dumps load a page at a time as the * private dumps and playlists too. The dumps load a page at a time as the
* "dumps" tab scrolls. A new `q` aborts the search under way. The socket keeps * "dumps" tab scrolls. A new `q` aborts the search under way. The socket keeps
* the dumps and playlists found live, as edited or deleted, but adds none. * the dumps and playlists found live, as edited or deleted, but adds none; a
* reconnection runs the search again, for every page of dumps loaded.
* *
* On the "dumps" and "playlists" tabs, a menu narrows the results to a * On the "dumps" and "playlists" tabs, a menu narrows the results to a
* category of their kind, kept in the URL's `?category=`, which another tab * category of their kind, kept in the URL's `?category=`, which another tab
@@ -113,13 +128,7 @@ export function Search(): React.JSX.Element {
categorySlug ?? "" categorySlug ?? ""
}` }`
: undefined, : undefined,
(signal) => (signal) => search(1, signal).then(toResults),
search(1, signal).then(({ dumps, users, playlists }) => ({
dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore),
dumpTotal: dumps.total ?? 0,
users: users.map(deserializePublicUser),
playlists: playlists.map(deserializePlaylist),
})),
); );
const dumps = state.status === "loaded" ? state.data.dumps : null; const dumps = state.status === "loaded" ? state.data.dumps : null;
@@ -159,6 +168,33 @@ export function Search(): React.JSX.Element {
{ isOwner: true, noNewEntries: true }, { isOwner: true, noNewEntries: true },
); );
// What changed while the socket was down: every page of dumps loaded is
// fetched again, and the users and playlists, which come with the first.
const searchRef = useRef(search);
useLayoutEffect(() => {
searchRef.current = search;
});
useOnReconnect(() => {
if (state.status !== "loaded") return;
const asked = search;
let first: Wire<SearchResults> | undefined;
refetchPages(state.data.dumps.page, (page) =>
asked(page).then((res) => {
if (page === 1) first = res;
return {
items: res.dumps.items.map(deserializeDump),
hasMore: res.dumps.hasMore,
};
}))
.then(({ items, hasMore }) => {
// The query changed meanwhile, and its own results are on screen.
if (!first || asked !== searchRef.current) return;
const fresh = toResults(first);
setData((d) => ({ ...fresh, dumps: { ...d.dumps, items, hasMore } }));
})
.catch(() => {});
});
const sentinelRef = useInfiniteScroll( const sentinelRef = useInfiniteScroll(
loadMore, loadMore,
tab === "dumps" && canLoadMore(dumps), tab === "dumps" && canLoadMore(dumps),

View File

@@ -33,6 +33,16 @@ describe("usePlaylistListSync", () => {
expect(ids(items())).toEqual(["b", "a"]); expect(ids(items())).toEqual(["b", "a"]);
}); });
test("a playlist made public goes where its date puts it", () => {
const at = (id: string, day: number) =>
makePlaylist({ id, createdAt: new Date(Date.UTC(2026, 0, day)) });
const { send, items } = setup([at("c", 30), at("a", 10)]);
send("created", at("b", 20));
// Older than every one loaded: a later page holds it.
send("created", at("old", 1));
expect(ids(items())).toEqual(["c", "b", "a"]);
});
test("created: filtered by owner, privacy and noNewEntries", () => { test("created: filtered by owner, privacy and noNewEntries", () => {
const p = makePlaylist({ id: "p", userId: "them" }); const p = makePlaylist({ id: "p", userId: "them" });
const byOwner = setup([], { ownerId: "me" }); const byOwner = setup([], { ownerId: "me" });

View File

@@ -264,6 +264,44 @@ describe("Index feeds", () => {
expect(screen.queryByText("A song")).not.toBeInTheDocument(); expect(screen.queryByText("A song")).not.toBeInTheDocument();
}); });
test("a dump edited into the category comes in where its date puts it", async () => {
const at = (minutes: number) => new Date(Date.now() - minutes * 60_000);
const tagged = (id: string, title: string, minutes: number) =>
makeDump({ id, title, categoryIds: ["c-music"], createdAt: at(minutes) });
route(
"GET /api/dumps",
() => page([tagged("n", "Newer", 1), tagged("o", "Older", 30)]),
);
const { socket } = setup("/music/new");
await screen.findByText("Older");
socket.receive({
type: "dump_updated",
dump: wire(tagged("m", "Now music", 10)),
});
expect(titles()).toEqual(["Newer", "Now music", "Older"]);
});
test("a dump made public long after it was posted goes by its date, not on top", async () => {
const at = (minutes: number) => new Date(Date.now() - minutes * 60_000);
route("GET /api/dumps", () =>
page([
makeDump({ id: "n", title: "Newer", createdAt: at(1) }),
makeDump({ id: "o", title: "Older", createdAt: at(30) }),
]));
const { socket } = setup("/new");
await screen.findByText("Older");
// To everyone but its poster, it arrives as created.
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "m", title: "Unveiled", createdAt: at(10) })),
});
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "a", title: "Ancient", createdAt: at(600) })),
});
expect(titles()).toEqual(["Newer", "Unveiled", "Older", "Ancient"]);
});
test("an unknown category is a 404", async () => { test("an unknown category is a 404", async () => {
route("GET /api/dumps", () => fail("nope", 404)); route("GET /api/dumps", () => fail("nope", 404));
setup("/nonsense/new"); setup("/nonsense/new");

View File

@@ -238,4 +238,50 @@ describe("Search", () => {
await user.click(screen.getByRole("button", { name: /Playlists/ })); await user.click(screen.getByRole("button", { name: /Playlists/ }));
expect(screen.queryByText("Cats")).not.toBeInTheDocument(); expect(screen.queryByText("Cats")).not.toBeInTheDocument();
}); });
test("a reconnect runs the search again, for every page loaded", async () => {
route(
"GET /api/search",
(req) =>
req.url.searchParams.get("page") === "2"
? results({ dumps: [makeDump({ title: "Second page" })] })
: results({
dumps: [makeDump({ title: "First page" })],
hasMore: true,
users: [makeUser({ username: "catlover" })],
}),
);
open("/search?q=cat");
const socket = FakeWebSocket.last;
socket.welcome();
await screen.findByText("First page");
const sentinel = FakeIntersectionObserver.instances.findLast((o) =>
o.elements.size > 0
)!;
act(() => sentinel.report(true));
await screen.findByText("Second page");
// Gone while the socket was down: an edit, a deletion, a new user.
route(
"GET /api/search",
(req) =>
req.url.searchParams.get("page") === "2"
? results({ dumps: [] })
: results({
dumps: [makeDump({ title: "First page, edited" })],
hasMore: true,
users: [
makeUser({ username: "catlover" }),
makeUser({ username: "cat2" }),
],
}),
);
socket.drop();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
expect(await screen.findByText("First page, edited")).toBeInTheDocument();
expect(screen.queryByText("Second page")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Users (2)" }))
.toBeInTheDocument();
});
}); });

View File

@@ -5,7 +5,9 @@ import {
pageUrl, pageUrl,
readFirstPage, readFirstPage,
readPage, readPage,
refetchPages,
restoredList, restoredList,
withByDate,
withFirst, withFirst,
} from "../../../utils/feed/pagedList.ts"; } from "../../../utils/feed/pagedList.ts";
import { calls, fail, route } from "../../fetch.ts"; import { calls, fail, route } from "../../fetch.ts";
@@ -57,4 +59,61 @@ describe("pagedList", () => {
const items = [a, b]; const items = [a, b];
expect(withFirst(items, { id: "b" })).toBe(items); expect(withFirst(items, { id: "b" })).toBe(items);
}); });
test("withByDate puts an item where its date falls, newest first", () => {
const at = (id: string, day: number) => ({
id,
createdAt: new Date(Date.UTC(2026, 0, day)),
});
const list = [at("c", 30), at("b", 20), at("a", 10)];
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
expect(ids(withByDate(list, at("new", 31)))).toEqual([
"new",
"c",
"b",
"a",
]);
expect(ids(withByDate(list, at("mid", 25)))).toEqual([
"c",
"mid",
"b",
"a",
]);
// A tie goes first, as something new does.
expect(ids(withByDate(list, at("tie", 20)))).toEqual([
"c",
"tie",
"b",
"a",
]);
// Older than everything loaded: a later page holds it.
expect(withByDate(list, at("old", 1))).toBe(list);
expect(withByDate(list, list[1])).toBe(list);
expect(ids(withByDate([], at("only", 1)))).toEqual(["only"]);
});
test("refetchPages reads each page again, each item once", async () => {
const pages: Record<number, { id: string }[]> = {
1: [{ id: "new" }, { id: "a" }],
// Shifted by the new item: "a" is on both.
2: [{ id: "a" }, { id: "b" }],
};
const result = await refetchPages(
2,
(n) => Promise.resolve({ items: pages[n], hasMore: n === 1 }),
);
expect(result).toEqual({
items: [{ id: "new" }, { id: "a" }, { id: "b" }],
hasMore: false,
});
await expect(
refetchPages(
2,
(n) =>
n === 2
? Promise.reject(new Error("offline"))
: Promise.resolve({ items: [], hasMore: true }),
),
).rejects.toThrow("offline");
});
}); });

View File

@@ -103,3 +103,46 @@ export async function readFirstPage<T>(
export function withFirst<T extends { id: string }>(items: T[], item: T): T[] { export function withFirst<T extends { id: string }>(items: T[], item: T): T[] {
return items.some((i) => i.id === item.id) ? items : [item, ...items]; return items.some((i) => i.id === item.id) ? items : [item, ...items];
} }
/**
* Returns `items`, newest first as the API pages them, with `item` where its
* `createdAt` puts it: before the first item no newer than it. An item older
* than every one loaded is left to a later page, and `items` returned as they
* are, as they are when `item` is already there. The list sync hooks put a
* new or newly visible dump or playlist in a list with it.
*/
export function withByDate<T extends { id: string; createdAt: Date }>(
items: T[],
item: T,
): T[] {
if (items.some((i) => i.id === item.id)) return items;
if (items.length === 0) return [item];
const time = item.createdAt.getTime();
const idx = items.findIndex((i) => i.createdAt.getTime() <= time);
if (idx === -1) return items;
return [...items.slice(0, idx), item, ...items.slice(idx)];
}
/**
* Fetches pages 1 to `pages` of a list again, and resolves to their items,
* each once, and whether more follow. An item added between two pages'
* fetches shifts the later page by one, which would list an item twice. A
* live list calls it after a reconnection, through `usePagedList`'s `resync`
* or, for the search's dumps, directly; it rejects when a page fails.
*/
export async function refetchPages<T extends { id: string }>(
pages: number,
fetchPage: (page: number) => Promise<PaginatedData<T>>,
): Promise<PaginatedData<T>> {
const results = await Promise.all(
Array.from({ length: pages }, (_, i) => fetchPage(i + 1)),
);
const byId = new Map<string, T>();
for (const item of results.flatMap((r) => r.items)) {
if (!byId.has(item.id)) byId.set(item.id, item);
}
return {
items: [...byId.values()],
hasMore: results[results.length - 1].hasMore,
};
}