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",
"version": "0.1.0",
"version": "0.1.1",
"description": "Topic-based peer discovery and signaling server for distributed P2P applications",
"main": "dist/index.js",
"scripts": {

View File

@@ -231,22 +231,29 @@ export function createApp(storage: Storage, config: Config) {
* GET /topics
* List all topics with active peer counts (paginated)
* 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) => {
try {
const limitParam = c.req.query('limit');
const offsetParam = c.req.query('offset');
const startsWithParam = c.req.query('startsWith');
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
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({
topics: result.topics,
total: result.total,
limit,
offset
offset,
...(startsWith && { startsWith })
}, 200);
} catch (err) {
console.error('Error fetching topics:', err);

View File

@@ -296,32 +296,51 @@ export class D1Storage implements Storage {
return candidates;
}
async getTopics(limit: number, offset: number): Promise<{
async getTopics(limit: number, offset: number, startsWith?: string): Promise<{
topics: TopicInfo[];
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
const countResult = await this.db.prepare(`
const countQuery = `
SELECT COUNT(DISTINCT ot.topic) as count
FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id
WHERE o.expires_at > ?
`).bind(Date.now()).first();
WHERE ${whereClause}
`;
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;
// Get topics with peer counts (paginated)
const topicsResult = await this.db.prepare(`
const topicsQuery = `
SELECT
ot.topic,
COUNT(DISTINCT o.peer_id) as active_peers
FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id
WHERE o.expires_at > ?
WHERE ${whereClause}
GROUP BY ot.topic
ORDER BY active_peers DESC, ot.topic ASC
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) => ({
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[];
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
const countStmt = this.db.prepare(`
const countQuery = `
SELECT COUNT(DISTINCT ot.topic) as count
FROM offer_topics ot
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;
// Get topics with peer counts (paginated)
const topicsStmt = this.db.prepare(`
const topicsQuery = `
SELECT
ot.topic,
COUNT(DISTINCT o.peer_id) as active_peers
FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id
WHERE o.expires_at > ?
WHERE ${whereClause}
GROUP BY ot.topic
ORDER BY active_peers DESC, ot.topic ASC
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 => ({
topic: row.topic,

View File

@@ -146,9 +146,10 @@ export interface Storage {
* Retrieves topics with active peer counts (paginated)
* @param limit Maximum number of topics to return
* @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
*/
getTopics(limit: number, offset: number): Promise<{
getTopics(limit: number, offset: number, startsWith?: string): Promise<{
topics: TopicInfo[];
total: number;
}>;