Add startsWith filter to topics endpoint

Added optional startsWith parameter to GET /topics endpoint:
- Filters topics by prefix using SQL LIKE operator
- Updated storage interface and implementations (SQLite & D1)
- Added query param documentation
- Returns startsWith in response when used

Version bumped to 0.1.1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-16 20:41:47 +01:00
parent 1a3976ccbc
commit f58e6e1014
5 changed files with 62 additions and 20 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/rondevu-server", "name": "@xtr-dev/rondevu-server",
"version": "0.1.0", "version": "0.1.1",
"description": "Topic-based peer discovery and signaling server for distributed P2P applications", "description": "Topic-based peer discovery and signaling server for distributed P2P applications",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {

View File

@@ -231,22 +231,29 @@ export function createApp(storage: Storage, config: Config) {
* GET /topics * GET /topics
* List all topics with active peer counts (paginated) * List all topics with active peer counts (paginated)
* Public endpoint (no auth required) * Public endpoint (no auth required)
* Query params:
* - limit: Max topics to return (default 50, max 200)
* - offset: Number of topics to skip (default 0)
* - startsWith: Filter topics starting with this prefix (optional)
*/ */
app.get('/topics', async (c) => { app.get('/topics', async (c) => {
try { try {
const limitParam = c.req.query('limit'); const limitParam = c.req.query('limit');
const offsetParam = c.req.query('offset'); const offsetParam = c.req.query('offset');
const startsWithParam = c.req.query('startsWith');
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50; const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
const offset = offsetParam ? parseInt(offsetParam, 10) : 0; const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
const startsWith = startsWithParam || undefined;
const result = await storage.getTopics(limit, offset); const result = await storage.getTopics(limit, offset, startsWith);
return c.json({ return c.json({
topics: result.topics, topics: result.topics,
total: result.total, total: result.total,
limit, limit,
offset offset,
...(startsWith && { startsWith })
}, 200); }, 200);
} catch (err) { } catch (err) {
console.error('Error fetching topics:', err); console.error('Error fetching topics:', err);

View File

@@ -296,32 +296,51 @@ export class D1Storage implements Storage {
return candidates; return candidates;
} }
async getTopics(limit: number, offset: number): Promise<{ async getTopics(limit: number, offset: number, startsWith?: string): Promise<{
topics: TopicInfo[]; topics: TopicInfo[];
total: number; total: number;
}> { }> {
const now = Date.now();
// Build WHERE clause for startsWith filter
const whereClause = startsWith
? 'o.expires_at > ? AND ot.topic LIKE ?'
: 'o.expires_at > ?';
const startsWithPattern = startsWith ? `${startsWith}%` : null;
// Get total count of topics with active offers // Get total count of topics with active offers
const countResult = await this.db.prepare(` const countQuery = `
SELECT COUNT(DISTINCT ot.topic) as count SELECT COUNT(DISTINCT ot.topic) as count
FROM offer_topics ot FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id INNER JOIN offers o ON ot.offer_id = o.id
WHERE o.expires_at > ? WHERE ${whereClause}
`).bind(Date.now()).first(); `;
const countStmt = this.db.prepare(countQuery);
const countResult = startsWith
? await countStmt.bind(now, startsWithPattern).first()
: await countStmt.bind(now).first();
const total = (countResult as any)?.count || 0; const total = (countResult as any)?.count || 0;
// Get topics with peer counts (paginated) // Get topics with peer counts (paginated)
const topicsResult = await this.db.prepare(` const topicsQuery = `
SELECT SELECT
ot.topic, ot.topic,
COUNT(DISTINCT o.peer_id) as active_peers COUNT(DISTINCT o.peer_id) as active_peers
FROM offer_topics ot FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id INNER JOIN offers o ON ot.offer_id = o.id
WHERE o.expires_at > ? WHERE ${whereClause}
GROUP BY ot.topic GROUP BY ot.topic
ORDER BY active_peers DESC, ot.topic ASC ORDER BY active_peers DESC, ot.topic ASC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`).bind(Date.now(), limit, offset).all(); `;
const topicsStmt = this.db.prepare(topicsQuery);
const topicsResult = startsWith
? await topicsStmt.bind(now, startsWithPattern, limit, offset).all()
: await topicsStmt.bind(now, limit, offset).all();
const topics = (topicsResult.results || []).map((row: any) => ({ const topics = (topicsResult.results || []).map((row: any) => ({
topic: row.topic, topic: row.topic,

View File

@@ -305,35 +305,50 @@ export class SQLiteStorage implements Storage {
})); }));
} }
async getTopics(limit: number, offset: number): Promise<{ async getTopics(limit: number, offset: number, startsWith?: string): Promise<{
topics: TopicInfo[]; topics: TopicInfo[];
total: number; total: number;
}> { }> {
const now = Date.now();
// Build WHERE clause for startsWith filter
const whereClause = startsWith
? 'o.expires_at > ? AND ot.topic LIKE ?'
: 'o.expires_at > ?';
const startsWithPattern = startsWith ? `${startsWith}%` : null;
// Get total count of topics with active offers // Get total count of topics with active offers
const countStmt = this.db.prepare(` const countQuery = `
SELECT COUNT(DISTINCT ot.topic) as count SELECT COUNT(DISTINCT ot.topic) as count
FROM offer_topics ot FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id INNER JOIN offers o ON ot.offer_id = o.id
WHERE o.expires_at > ? WHERE ${whereClause}
`); `;
const countRow = countStmt.get(Date.now()) as any; const countStmt = this.db.prepare(countQuery);
const countParams = startsWith ? [now, startsWithPattern] : [now];
const countRow = countStmt.get(...countParams) as any;
const total = countRow.count; const total = countRow.count;
// Get topics with peer counts (paginated) // Get topics with peer counts (paginated)
const topicsStmt = this.db.prepare(` const topicsQuery = `
SELECT SELECT
ot.topic, ot.topic,
COUNT(DISTINCT o.peer_id) as active_peers COUNT(DISTINCT o.peer_id) as active_peers
FROM offer_topics ot FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id INNER JOIN offers o ON ot.offer_id = o.id
WHERE o.expires_at > ? WHERE ${whereClause}
GROUP BY ot.topic GROUP BY ot.topic
ORDER BY active_peers DESC, ot.topic ASC ORDER BY active_peers DESC, ot.topic ASC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`); `;
const rows = topicsStmt.all(Date.now(), limit, offset) as any[]; const topicsStmt = this.db.prepare(topicsQuery);
const topicsParams = startsWith
? [now, startsWithPattern, limit, offset]
: [now, limit, offset];
const rows = topicsStmt.all(...topicsParams) as any[];
const topics = rows.map(row => ({ const topics = rows.map(row => ({
topic: row.topic, topic: row.topic,

View File

@@ -146,9 +146,10 @@ export interface Storage {
* Retrieves topics with active peer counts (paginated) * Retrieves topics with active peer counts (paginated)
* @param limit Maximum number of topics to return * @param limit Maximum number of topics to return
* @param offset Number of topics to skip * @param offset Number of topics to skip
* @param startsWith Optional prefix filter - only return topics starting with this string
* @returns Object with topics array and total count * @returns Object with topics array and total count
*/ */
getTopics(limit: number, offset: number): Promise<{ getTopics(limit: number, offset: number, startsWith?: string): Promise<{
topics: TopicInfo[]; topics: TopicInfo[];
total: number; total: number;
}>; }>;