feat(26-01): implement discovery service with cursor pagination

- getPopularSetups: public setups ordered by item count desc, composite cursor pagination
- getRecentGlobalItems: global items ordered by createdAt desc, ISO timestamp cursor
- getTrendingCategories: category counts ordered desc, null categories excluded, simple limit
- Shared CursorPage<T> response shape with hasMore and nextCursor fields
This commit is contained in:
2026-04-10 14:54:13 +02:00
parent 06b6e935f2
commit d1f8a7aa4c

View File

@@ -0,0 +1,130 @@
import { and, count, desc, eq, isNotNull, lt, sql } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { globalItems, setups, setupItems, users } from "../../db/schema.ts";
type Db = typeof prodDb;
interface CursorPage<T> {
items: T[];
nextCursor: string | null;
hasMore: boolean;
}
/**
* Get popular public setups ordered by item count descending.
* Cursor format: "{itemCount}_{id}" for stable composite pagination.
* Only public setups (isPublic=true) are returned.
*/
export async function getPopularSetups(
db: Db = prodDb,
limit = 6,
cursor?: string,
): Promise<
CursorPage<{
id: number;
name: string;
createdAt: Date;
itemCount: number;
creatorName: string | null;
}>
> {
// Fetch more rows when cursor provided to ensure we can filter and still fill the page
const fetchLimit = cursor ? limit * 2 + limit + 1 : limit + 1;
const rows = await db
.select({
id: setups.id,
name: setups.name,
createdAt: setups.createdAt,
itemCount: sql<number>`CAST(COUNT(${setupItems.id}) AS INT)`,
creatorName: users.displayName,
})
.from(setups)
.leftJoin(setupItems, eq(setupItems.setupId, setups.id))
.leftJoin(users, eq(users.id, setups.userId))
.where(eq(setups.isPublic, true))
.groupBy(setups.id, setups.name, setups.createdAt, users.displayName)
.orderBy(
desc(sql<number>`COUNT(${setupItems.id})`),
desc(setups.id),
)
.limit(fetchLimit);
// Apply cursor filter in JS (composite cursor: itemCount_id)
let filtered = rows;
if (cursor) {
const parts = cursor.split("_");
const cursorCount = Number(parts[0]);
const cursorId = Number(parts[1]);
filtered = rows.filter(
(r) =>
r.itemCount < cursorCount ||
(r.itemCount === cursorCount && r.id < cursorId),
);
}
const hasMore = filtered.length > limit;
const items = hasMore ? filtered.slice(0, limit) : filtered;
const nextCursor =
hasMore && items.length > 0
? `${items[items.length - 1].itemCount}_${items[items.length - 1].id}`
: null;
return { items, nextCursor, hasMore };
}
/**
* Get recently added global catalog items ordered by createdAt descending.
* Cursor is an ISO timestamp string — returns items created before that time.
*/
export async function getRecentGlobalItems(
db: Db = prodDb,
limit = 8,
cursor?: string,
): Promise<CursorPage<typeof globalItems.$inferSelect>> {
const conditions = cursor
? [lt(globalItems.createdAt, new Date(cursor))]
: [];
const rows = await db
.select()
.from(globalItems)
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(desc(globalItems.createdAt))
.limit(limit + 1);
const hasMore = rows.length > limit;
const items = hasMore ? rows.slice(0, limit) : rows;
const nextCursor =
hasMore && items.length > 0
? items[items.length - 1].createdAt.toISOString()
: null;
return { items, nextCursor, hasMore };
}
/**
* Get trending categories ordered by global item count descending.
* Excludes items where category IS NULL.
* Simple limit pagination — no cursor (categories are a bounded list).
*/
export async function getTrendingCategories(
db: Db = prodDb,
limit = 12,
): Promise<Array<{ name: string; itemCount: number }>> {
const rows = await db
.select({
name: globalItems.category,
itemCount: count(globalItems.id),
})
.from(globalItems)
.where(isNotNull(globalItems.category))
.groupBy(globalItems.category)
.orderBy(desc(count(globalItems.id)))
.limit(limit);
return rows.map((r) => ({
name: r.name as string,
itemCount: r.itemCount,
}));
}