/** * RepositoryService — CRUD operations for repositories. * Operates directly on the raw better-sqlite3 client for synchronous queries. */ import type Database from 'better-sqlite3'; import type { Repository, NewRepository, IndexingJob, NewIndexingJob } from '$lib/types'; import { resolveGitHubId, resolveLocalId } from '$lib/server/utils/id-resolver'; import { AlreadyExistsError, InvalidInputError, InvalidUrlError, NotFoundError } from '$lib/server/utils/validation'; export interface AddRepositoryInput { source: 'github' | 'local'; sourceUrl: string; title?: string; description?: string; branch?: string; githubToken?: string; } export interface UpdateRepositoryInput { title?: string; description?: string; branch?: string; githubToken?: string; } export interface RepositoryStats { totalSnippets: number; totalTokens: number; totalDocuments: number; lastIndexedAt: Date | null; } export class RepositoryService { constructor(private readonly db: Database.Database) {} /** * List all repositories with optional filtering. */ list(options?: { state?: Repository['state']; limit?: number; offset?: number }): Repository[] { const limit = options?.limit ?? 50; const offset = options?.offset ?? 0; if (options?.state) { return this.db .prepare( `SELECT * FROM repositories WHERE state = ? ORDER BY created_at DESC LIMIT ? OFFSET ?` ) .all(options.state, limit, offset) as Repository[]; } return this.db .prepare(`SELECT * FROM repositories ORDER BY created_at DESC LIMIT ? OFFSET ?`) .all(limit, offset) as Repository[]; } /** * Count total repositories (optionally filtered by state). */ count(state?: Repository['state']): number { if (state) { const row = this.db .prepare(`SELECT COUNT(*) as n FROM repositories WHERE state = ?`) .get(state) as { n: number }; return row.n; } const row = this.db .prepare(`SELECT COUNT(*) as n FROM repositories`) .get() as { n: number }; return row.n; } /** * Get a single repository by ID. */ get(id: string): Repository | null { return ( (this.db.prepare(`SELECT * FROM repositories WHERE id = ?`).get(id) as Repository | undefined) ?? null ); } /** * Add a new repository. Generates the canonical ID and queues an indexing job. */ add(input: AddRepositoryInput): Repository { // Validate required fields if (!input.sourceUrl?.trim()) { throw new InvalidInputError('sourceUrl is required', [ { field: 'sourceUrl', message: 'sourceUrl is required' } ]); } // Derive canonical ID let id: string; let title: string; if (input.source === 'github') { try { id = resolveGitHubId(input.sourceUrl); } catch { throw new InvalidUrlError( `Invalid GitHub URL: ${input.sourceUrl}. Expected format: https://github.com/owner/repo` ); } // Default title from owner/repo const parts = id.split('/').filter(Boolean); title = input.title ?? (parts[1] ?? id); } else { // local const existing = this.list({ limit: 9999 }).map((r) => r.id); id = resolveLocalId(input.sourceUrl, existing); const parts = input.sourceUrl.split('/'); title = input.title ?? (parts.at(-1) ?? 'local-repo'); } // Check for collision const existing = this.get(id); if (existing) { throw new AlreadyExistsError(`Repository ${id} already exists`); } const now = Math.floor(Date.now() / 1000); const repo: Record = { id, title, description: input.description ?? null, source: input.source, source_url: input.sourceUrl, branch: input.branch ?? 'main', state: 'pending', total_snippets: 0, total_tokens: 0, trust_score: 0, benchmark_score: 0, stars: null, github_token: input.githubToken ?? null, last_indexed_at: null, created_at: now, updated_at: now }; this.db .prepare( `INSERT INTO repositories (id, title, description, source, source_url, branch, state, total_snippets, total_tokens, trust_score, benchmark_score, stars, github_token, last_indexed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( repo.id, repo.title, repo.description, repo.source, repo.source_url, repo.branch, repo.state, repo.total_snippets, repo.total_tokens, repo.trust_score, repo.benchmark_score, repo.stars, repo.github_token, repo.last_indexed_at, repo.created_at, repo.updated_at ); return this.get(id)!; } /** * Update repository metadata. */ update(id: string, input: UpdateRepositoryInput): Repository { const existing = this.get(id); if (!existing) throw new NotFoundError(`Repository ${id} not found`); const now = Math.floor(Date.now() / 1000); const updates: string[] = []; const values: unknown[] = []; if (input.title !== undefined) { updates.push('title = ?'); values.push(input.title); } if (input.description !== undefined) { updates.push('description = ?'); values.push(input.description); } if (input.branch !== undefined) { updates.push('branch = ?'); values.push(input.branch); } if (input.githubToken !== undefined) { updates.push('github_token = ?'); values.push(input.githubToken); } if (updates.length === 0) return existing; updates.push('updated_at = ?'); values.push(now); values.push(id); this.db.prepare(`UPDATE repositories SET ${updates.join(', ')} WHERE id = ?`).run(...values); return this.get(id)!; } /** * Delete a repository and all associated data (cascades via FK). */ remove(id: string): void { const existing = this.get(id); if (!existing) throw new NotFoundError(`Repository ${id} not found`); this.db.prepare(`DELETE FROM repositories WHERE id = ?`).run(id); } /** * Get aggregate statistics for a repository. */ getStats(id: string): RepositoryStats { const existing = this.get(id); if (!existing) throw new NotFoundError(`Repository ${id} not found`); const snippetStats = this.db .prepare( `SELECT COUNT(*) as total_snippets, COALESCE(SUM(token_count), 0) as total_tokens FROM snippets WHERE repository_id = ?` ) .get(id) as { total_snippets: number; total_tokens: number }; const docStats = this.db .prepare(`SELECT COUNT(*) as total_documents FROM documents WHERE repository_id = ?`) .get(id) as { total_documents: number }; return { totalSnippets: snippetStats.total_snippets, totalTokens: snippetStats.total_tokens, totalDocuments: docStats.total_documents, lastIndexedAt: existing.lastIndexedAt }; } /** * Get all versions for a repository. */ getVersions(repositoryId: string): string[] { const rows = this.db .prepare( `SELECT tag FROM repository_versions WHERE repository_id = ? ORDER BY created_at DESC` ) .all(repositoryId) as { tag: string }[]; return rows.map((r) => r.tag); } /** * Create an indexing job for a repository. * If a job is already running, returns the existing job. */ createIndexingJob(repositoryId: string, versionId?: string): IndexingJob { // Check for running job const runningJob = this.db .prepare( `SELECT * FROM indexing_jobs WHERE repository_id = ? AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1` ) .get(repositoryId) as IndexingJob | undefined; if (runningJob) return runningJob; const now = Math.floor(Date.now() / 1000); const job: Record = { id: crypto.randomUUID(), repository_id: repositoryId, version_id: versionId ?? null, status: 'queued', progress: 0, total_files: 0, processed_files: 0, error: null, started_at: null, completed_at: null, created_at: now }; this.db .prepare( `INSERT INTO indexing_jobs (id, repository_id, version_id, status, progress, total_files, processed_files, error, started_at, completed_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( job.id, job.repository_id, job.version_id, job.status, job.progress, job.total_files, job.processed_files, job.error, job.started_at, job.completed_at, job.created_at ); return this.db .prepare(`SELECT * FROM indexing_jobs WHERE id = ?`) .get(job.id) as IndexingJob; } }