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:
130
src/server/services/discovery.service.ts
Normal file
130
src/server/services/discovery.service.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user