Files
awesome-app/lib/db.ts
valknarness d7c095fdb7 fix: search API FTS join and error handling
Fixed 500 Internal Server Error in search API by correcting the FTS5
full-text search query join. The previous query incorrectly joined
fts.rowid directly to repositories.id, but FTS rowid corresponds to
the readmes table. Now properly joins through readmes table first.

Also enhanced error logging with detailed error messages in development
mode for easier debugging.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 06:52:08 +01:00

337 lines
7.6 KiB
TypeScript

import Database from 'better-sqlite3'
import { join } from 'path'
import { existsSync } from 'fs'
// Database path - using user's .awesome directory
const DB_PATH = process.env.AWESOME_DB_PATH || join(process.env.HOME || '', '.awesome', 'awesome.db')
let db: Database.Database | null = null
export function getDb(): Database.Database {
if (!db) {
// Check if database file exists before trying to open it
if (!existsSync(DB_PATH)) {
throw new Error(`Database file not found at ${DB_PATH}`)
}
db = new Database(DB_PATH, { readonly: true })
// Enable WAL mode for better concurrency
db.pragma('journal_mode = WAL')
}
return db
}
export interface AwesomeList {
id: number
name: string
url: string
description: string | null
category: string | null
stars: number | null
forks: number | null
last_commit: string | null
level: number | null
parent_id: number | null
added_at: string | null
last_updated: string | null
}
export interface Repository {
id: number
awesome_list_id: number
name: string
url: string
description: string | null
stars: number | null
forks: number | null
watchers: number | null
language: string | null
topics: string | null
last_commit: string | null
created_at: string | null
added_at: string | null
}
export interface Readme {
id: number
repository_id: number
content: string | null
raw_content: string | null
version_hash: string | null
indexed_at: string | null
}
export interface SearchResult {
repository_id: number
repository_name: string
repository_url: string
description: string | null
stars: number | null
language: string | null
topics: string | null
awesome_list_name: string | null
awesome_list_category: string | null
rank: number
snippet: string | null
}
export interface SearchOptions {
query: string
limit?: number
offset?: number
language?: string
minStars?: number
category?: string
sortBy?: 'relevance' | 'stars' | 'recent'
}
export interface PaginatedResults<T> {
results: T[]
total: number
page: number
pageSize: number
totalPages: number
}
/**
* Full-text search using FTS5
*/
export function searchRepositories(options: SearchOptions): PaginatedResults<SearchResult> {
const db = getDb()
const {
query,
limit = 20,
offset = 0,
language,
minStars,
category,
sortBy = 'relevance'
} = options
// Build FTS query
const ftsQuery = query
.trim()
.split(/\s+/)
.map(term => `"${term}"*`)
.join(' OR ')
let sql = `
SELECT
r.id as repository_id,
r.name as repository_name,
r.url as repository_url,
r.description,
r.stars,
r.language,
r.topics,
al.name as awesome_list_name,
al.category as awesome_list_category,
fts.rank,
snippet(readmes_fts, 2, '<mark>', '</mark>', '...', 32) as snippet
FROM readmes_fts fts
JOIN readmes rm ON fts.rowid = rm.rowid
JOIN repositories r ON rm.repository_id = r.id
LEFT JOIN awesome_lists al ON r.awesome_list_id = al.id
WHERE readmes_fts MATCH ?
`
const params: any[] = [ftsQuery]
// Add filters
if (language) {
sql += ` AND r.language = ?`
params.push(language)
}
if (minStars !== undefined) {
sql += ` AND r.stars >= ?`
params.push(minStars)
}
if (category) {
sql += ` AND al.category = ?`
params.push(category)
}
// Add sorting
switch (sortBy) {
case 'stars':
sql += ` ORDER BY r.stars DESC, fts.rank`
break
case 'recent':
sql += ` ORDER BY r.last_commit DESC, fts.rank`
break
default:
sql += ` ORDER BY fts.rank`
}
// Count total results (use [\s\S]*? for multiline matching)
const countSql = sql.replace(/SELECT[\s\S]*?FROM/, 'SELECT COUNT(*) as total FROM')
.replace(/ORDER BY[\s\S]*$/, '')
const totalResult = db.prepare(countSql).get(...params) as { total: number }
const total = totalResult.total
// Add pagination
sql += ` LIMIT ? OFFSET ?`
params.push(limit, offset)
const results = db.prepare(sql).all(...params) as SearchResult[]
const page = Math.floor(offset / limit) + 1
const totalPages = Math.ceil(total / limit)
return {
results,
total,
page,
pageSize: limit,
totalPages
}
}
/**
* Get all awesome lists with optional category filter
*/
export function getAwesomeLists(category?: string): AwesomeList[] {
const db = getDb()
let sql = `
SELECT * FROM awesome_lists
WHERE 1=1
`
const params: any[] = []
if (category) {
sql += ` AND category = ?`
params.push(category)
}
sql += ` ORDER BY stars DESC, name ASC`
return db.prepare(sql).all(...params) as AwesomeList[]
}
/**
* Get repositories by awesome list ID
*/
export function getRepositoriesByList(listId: number, limit = 50, offset = 0): PaginatedResults<Repository> {
const db = getDb()
const countSql = `SELECT COUNT(*) as total FROM repositories WHERE awesome_list_id = ?`
const totalResult = db.prepare(countSql).get(listId) as { total: number }
const total = totalResult.total
const sql = `
SELECT * FROM repositories
WHERE awesome_list_id = ?
ORDER BY stars DESC, name ASC
LIMIT ? OFFSET ?
`
const results = db.prepare(sql).all(listId, limit, offset) as Repository[]
const page = Math.floor(offset / limit) + 1
const totalPages = Math.ceil(total / limit)
return {
results,
total,
page,
pageSize: limit,
totalPages
}
}
/**
* Get repository by ID with README content
*/
export function getRepositoryWithReadme(repositoryId: number): (Repository & { readme: Readme | null }) | null {
const db = getDb()
const repo = db.prepare(`
SELECT * FROM repositories WHERE id = ?
`).get(repositoryId) as Repository | undefined
if (!repo) {
return null
}
const readme = db.prepare(`
SELECT raw_content as content FROM readmes WHERE repository_id = ?
`).get(repositoryId) as Readme | undefined
return {
...repo,
readme: readme || null
}
}
/**
* Get all unique categories
*/
export function getCategories(): { name: string; count: number }[] {
const db = getDb()
return db.prepare(`
SELECT
category as name,
COUNT(*) as count
FROM awesome_lists
WHERE category IS NOT NULL
GROUP BY category
ORDER BY count DESC, category ASC
`).all() as { name: string; count: number }[]
}
/**
* Get all unique languages
*/
export function getLanguages(): { name: string; count: number }[] {
const db = getDb()
return db.prepare(`
SELECT
language as name,
COUNT(*) as count
FROM repositories
WHERE language IS NOT NULL
GROUP BY language
ORDER BY count DESC, language ASC
LIMIT 50
`).all() as { name: string; count: number }[]
}
/**
* Get database statistics
*/
export function getStats() {
const db = getDb()
const stats = {
lists: db.prepare('SELECT COUNT(*) as count FROM awesome_lists').get() as { count: number },
repositories: db.prepare('SELECT COUNT(*) as count FROM repositories').get() as { count: number },
readmes: db.prepare('SELECT COUNT(*) as count FROM readmes').get() as { count: number },
lastUpdated: db.prepare('SELECT MAX(last_updated) as date FROM awesome_lists').get() as { date: string | null }
}
return {
totalLists: stats.lists.count,
totalRepositories: stats.repositories.count,
totalReadmes: stats.readmes.count,
lastUpdated: stats.lastUpdated.date
}
}
/**
* Get trending repositories (most stars)
*/
export function getTrendingRepositories(limit = 10): Repository[] {
const db = getDb()
return db.prepare(`
SELECT * FROM repositories
WHERE stars IS NOT NULL
ORDER BY stars DESC
LIMIT ?
`).all(limit) as Repository[]
}