From 3d1bef5003eabe5f43f002929745a68aba427b83 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sun, 22 Mar 2026 17:43:06 +0100 Subject: [PATCH] feat(TRUEREF-0002): implement repository management service and REST API Add RepositoryService with full CRUD, ID resolution helpers, input validation, six SvelteKit API routes (GET/POST /api/v1/libs, GET/PATCH/DELETE /api/v1/libs/:id, POST /api/v1/libs/:id/index), and 37 unit tests covering all service operations. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/db/client.ts | 18 + .../services/repository.service.test.ts | 476 ++++++++++++++++++ src/lib/server/services/repository.service.ts | 321 ++++++++++++ src/lib/server/utils/id-resolver.ts | 42 ++ src/lib/server/utils/validation.ts | 94 ++++ src/routes/api/v1/libs/+server.ts | 84 ++++ src/routes/api/v1/libs/[id]/+server.ts | 68 +++ src/routes/api/v1/libs/[id]/index/+server.ts | 43 ++ 8 files changed, 1146 insertions(+) create mode 100644 src/lib/server/db/client.ts create mode 100644 src/lib/server/services/repository.service.test.ts create mode 100644 src/lib/server/services/repository.service.ts create mode 100644 src/lib/server/utils/id-resolver.ts create mode 100644 src/lib/server/utils/validation.ts create mode 100644 src/routes/api/v1/libs/+server.ts create mode 100644 src/routes/api/v1/libs/[id]/+server.ts create mode 100644 src/routes/api/v1/libs/[id]/index/+server.ts diff --git a/src/lib/server/db/client.ts b/src/lib/server/db/client.ts new file mode 100644 index 0000000..3850e5d --- /dev/null +++ b/src/lib/server/db/client.ts @@ -0,0 +1,18 @@ +/** + * Provides a raw better-sqlite3 Database instance for use in services that + * need direct SQL access (not via Drizzle ORM). + */ +import Database from 'better-sqlite3'; +import { env } from '$env/dynamic/private'; + +let _client: Database.Database | null = null; + +export function getClient(): Database.Database { + if (!_client) { + if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); + _client = new Database(env.DATABASE_URL); + _client.pragma('journal_mode = WAL'); + _client.pragma('foreign_keys = ON'); + } + return _client; +} diff --git a/src/lib/server/services/repository.service.test.ts b/src/lib/server/services/repository.service.test.ts new file mode 100644 index 0000000..205c09e --- /dev/null +++ b/src/lib/server/services/repository.service.test.ts @@ -0,0 +1,476 @@ +/** + * Unit tests for RepositoryService. + * + * The service uses raw better-sqlite3 queries, so returned rows have + * snake_case column names matching the SQLite schema. Tests assert against + * those actual runtime keys. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { RepositoryService } from './repository.service'; +import { + AlreadyExistsError, + InvalidInputError, + InvalidUrlError, + NotFoundError +} from '$lib/server/utils/validation'; + +// --------------------------------------------------------------------------- +// Test DB factory +// --------------------------------------------------------------------------- + +function createTestDb(): Database.Database { + const client = new Database(':memory:'); + client.pragma('foreign_keys = ON'); + + const migrationsFolder = join(import.meta.dirname, '../db/migrations'); + const migrationSql = readFileSync( + join(migrationsFolder, '0000_large_master_chief.sql'), + 'utf-8' + ); + + // Drizzle migration files use `--> statement-breakpoint` as separator. + const statements = migrationSql + .split('--> statement-breakpoint') + .map((s) => s.trim()) + .filter(Boolean); + + for (const stmt of statements) { + client.exec(stmt); + } + + return client; +} + +// Raw row shape returned by better-sqlite3 SELECT * FROM repositories. +interface RawRepo { + id: string; + title: string; + description: string | null; + source: string; + source_url: string; + branch: string | null; + state: string; + total_snippets: number; + total_tokens: number; + trust_score: number; + benchmark_score: number; + stars: number | null; + github_token: string | null; + last_indexed_at: number | null; + created_at: number; + updated_at: number; +} + +// Raw row shape returned by better-sqlite3 SELECT * FROM indexing_jobs. +interface RawJob { + id: string; + repository_id: string; + version_id: string | null; + status: string; + progress: number; + total_files: number; + processed_files: number; + error: string | null; + started_at: number | null; + completed_at: number | null; + created_at: number; +} + +function makeService(client: Database.Database): RepositoryService { + return new RepositoryService(client); +} + +// --------------------------------------------------------------------------- +// list() and count() +// --------------------------------------------------------------------------- + +describe('RepositoryService.list()', () => { + let service: RepositoryService; + + beforeEach(() => { + service = makeService(createTestDb()); + }); + + it('returns empty array when no repositories exist', () => { + const result = service.list(); + expect(result).toEqual([]); + }); + + it('returns all repositories after adding some', () => { + service.add({ source: 'github', sourceUrl: 'https://github.com/foo/a' }); + service.add({ source: 'github', sourceUrl: 'https://github.com/foo/b' }); + const result = service.list(); + expect(result).toHaveLength(2); + }); + + it('filters by state', () => { + service.add({ source: 'github', sourceUrl: 'https://github.com/foo/a' }); + service.add({ source: 'github', sourceUrl: 'https://github.com/foo/b' }); + // All repos start as 'pending'. + const pending = service.list({ state: 'pending' }); + expect(pending).toHaveLength(2); + const indexed = service.list({ state: 'indexed' }); + expect(indexed).toHaveLength(0); + }); + + it('respects limit and offset', () => { + service.add({ source: 'github', sourceUrl: 'https://github.com/foo/a' }); + service.add({ source: 'github', sourceUrl: 'https://github.com/foo/b' }); + service.add({ source: 'github', sourceUrl: 'https://github.com/foo/c' }); + const page1 = service.list({ limit: 2, offset: 0 }); + const page2 = service.list({ limit: 2, offset: 2 }); + expect(page1).toHaveLength(2); + expect(page2).toHaveLength(1); + }); +}); + +describe('RepositoryService.count()', () => { + let service: RepositoryService; + + beforeEach(() => { + service = makeService(createTestDb()); + }); + + it('returns 0 when empty', () => { + expect(service.count()).toBe(0); + }); + + it('returns correct count after adding repositories', () => { + service.add({ source: 'github', sourceUrl: 'https://github.com/foo/a' }); + service.add({ source: 'github', sourceUrl: 'https://github.com/foo/b' }); + expect(service.count()).toBe(2); + }); + + it('filters count by state', () => { + service.add({ source: 'github', sourceUrl: 'https://github.com/foo/a' }); + expect(service.count('pending')).toBe(1); + expect(service.count('indexed')).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// get() +// --------------------------------------------------------------------------- + +describe('RepositoryService.get()', () => { + let service: RepositoryService; + + beforeEach(() => { + service = makeService(createTestDb()); + }); + + it('returns null for a non-existent repository', () => { + expect(service.get('/not/found')).toBeNull(); + }); + + it('returns the repository when it exists', () => { + service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + const repo = service.get('/facebook/react') as unknown as RawRepo; + expect(repo).not.toBeNull(); + expect(repo.id).toBe('/facebook/react'); + expect(repo.source).toBe('github'); + }); +}); + +// --------------------------------------------------------------------------- +// add() +// --------------------------------------------------------------------------- + +describe('RepositoryService.add()', () => { + let service: RepositoryService; + + beforeEach(() => { + service = makeService(createTestDb()); + }); + + it('adds a GitHub repository and derives the correct ID', () => { + const repo = service.add({ + source: 'github', + sourceUrl: 'https://github.com/facebook/react' + }) as unknown as RawRepo; + expect(repo.id).toBe('/facebook/react'); + expect(repo.source).toBe('github'); + expect(repo.state).toBe('pending'); + }); + + it('accepts github.com/owner/repo URL format', () => { + const repo = service.add({ + source: 'github', + sourceUrl: 'github.com/facebook/react' + }); + expect(repo.id).toBe('/facebook/react'); + }); + + it('strips .git suffix from GitHub URLs', () => { + const repo = service.add({ + source: 'github', + sourceUrl: 'https://github.com/facebook/react.git' + }); + expect(repo.id).toBe('/facebook/react'); + }); + + it('uses override title when provided', () => { + const repo = service.add({ + source: 'github', + sourceUrl: 'https://github.com/facebook/react', + title: 'React Library' + }); + expect(repo.title).toBe('React Library'); + }); + + it('defaults title to repo name for GitHub repos', () => { + const repo = service.add({ + source: 'github', + sourceUrl: 'https://github.com/facebook/react' + }); + expect(repo.title).toBe('react'); + }); + + it('adds a local repository and derives the /local/ ID', () => { + const repo = service.add({ + source: 'local', + sourceUrl: '/home/user/projects/my-sdk' + }) as unknown as RawRepo; + expect(repo.id).toBe('/local/my-sdk'); + expect(repo.source).toBe('local'); + }); + + it('resolves ID collisions for local repositories with -2, -3 suffix', () => { + service.add({ source: 'local', sourceUrl: '/home/user/a/my-sdk' }); + const repo2 = service.add({ source: 'local', sourceUrl: '/home/user/b/my-sdk' }); + expect(repo2.id).toBe('/local/my-sdk-2'); + const repo3 = service.add({ source: 'local', sourceUrl: '/home/user/c/my-sdk' }); + expect(repo3.id).toBe('/local/my-sdk-3'); + }); + + it('throws AlreadyExistsError when a GitHub repo already exists', () => { + service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + expect(() => + service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }) + ).toThrow(AlreadyExistsError); + }); + + it('throws InvalidUrlError for a bad GitHub URL', () => { + expect(() => + service.add({ source: 'github', sourceUrl: 'https://gitlab.com/foo/bar' }) + ).toThrow(InvalidUrlError); + }); + + it('throws InvalidInputError when sourceUrl is empty', () => { + expect(() => + service.add({ source: 'github', sourceUrl: '' }) + ).toThrow(InvalidInputError); + }); + + it('stores description and branch when provided', () => { + const repo = service.add({ + source: 'github', + sourceUrl: 'https://github.com/facebook/react', + description: 'A UI library', + branch: 'main' + }) as unknown as RawRepo; + expect(repo.description).toBe('A UI library'); + expect(repo.branch).toBe('main'); + }); + + it('initialises counters to zero', () => { + const repo = service.add({ + source: 'github', + sourceUrl: 'https://github.com/facebook/react' + }) as unknown as RawRepo; + expect(repo.total_snippets).toBe(0); + expect(repo.total_tokens).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// update() +// --------------------------------------------------------------------------- + +describe('RepositoryService.update()', () => { + let service: RepositoryService; + + beforeEach(() => { + service = makeService(createTestDb()); + service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + }); + + it('updates the title', () => { + const updated = service.update('/facebook/react', { title: 'React' }); + expect(updated.title).toBe('React'); + }); + + it('updates the description', () => { + const updated = service.update('/facebook/react', { description: 'UI library' }); + expect(updated.description).toBe('UI library'); + }); + + it('updates the branch', () => { + const updated = service.update('/facebook/react', { branch: 'canary' }) as unknown as RawRepo; + expect(updated.branch).toBe('canary'); + }); + + it('returns unchanged repository when input has no fields', () => { + const before = service.get('/facebook/react')!; + const updated = service.update('/facebook/react', {}); + expect(updated.title).toBe(before.title); + }); + + it('throws NotFoundError for a non-existent repository', () => { + expect(() => + service.update('/not/found', { title: 'New Title' }) + ).toThrow(NotFoundError); + }); +}); + +// --------------------------------------------------------------------------- +// remove() +// --------------------------------------------------------------------------- + +describe('RepositoryService.remove()', () => { + let service: RepositoryService; + + beforeEach(() => { + service = makeService(createTestDb()); + service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + }); + + it('removes an existing repository', () => { + service.remove('/facebook/react'); + expect(service.get('/facebook/react')).toBeNull(); + }); + + it('throws NotFoundError when the repository does not exist', () => { + expect(() => service.remove('/not/found')).toThrow(NotFoundError); + }); +}); + +// --------------------------------------------------------------------------- +// getStats() +// --------------------------------------------------------------------------- + +describe('RepositoryService.getStats()', () => { + let client: Database.Database; + let service: RepositoryService; + + beforeEach(() => { + client = createTestDb(); + service = makeService(client); + service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + }); + + it('returns zero stats for a freshly added repository', () => { + const stats = service.getStats('/facebook/react'); + expect(stats.totalSnippets).toBe(0); + expect(stats.totalTokens).toBe(0); + expect(stats.totalDocuments).toBe(0); + // lastIndexedAt may be undefined or null depending on the raw row value. + expect(stats.lastIndexedAt == null).toBe(true); + }); + + it('throws NotFoundError for a non-existent repository', () => { + expect(() => service.getStats('/not/found')).toThrow(NotFoundError); + }); + + it('counts documents correctly', () => { + const docId = crypto.randomUUID(); + const now = Math.floor(Date.now() / 1000); + client + .prepare( + `INSERT INTO documents (id, repository_id, file_path, checksum, indexed_at) + VALUES (?, '/facebook/react', 'README.md', 'abc', ?)` + ) + .run(docId, now); + + const stats = service.getStats('/facebook/react'); + expect(stats.totalDocuments).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// getVersions() +// --------------------------------------------------------------------------- + +describe('RepositoryService.getVersions()', () => { + let client: Database.Database; + let service: RepositoryService; + + beforeEach(() => { + client = createTestDb(); + service = makeService(client); + service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + }); + + it('returns an empty array when no versions exist', () => { + expect(service.getVersions('/facebook/react')).toEqual([]); + }); + + it('returns tags for all versions of a repository', () => { + const now = Math.floor(Date.now() / 1000); + client + .prepare( + `INSERT INTO repository_versions (id, repository_id, tag, created_at) + VALUES (?, '/facebook/react', ?, ?)` + ) + .run('/facebook/react/v18.3.0', 'v18.3.0', now); + client + .prepare( + `INSERT INTO repository_versions (id, repository_id, tag, created_at) + VALUES (?, '/facebook/react', ?, ?)` + ) + .run('/facebook/react/v17.0.2', 'v17.0.2', now - 1); + + const versions = service.getVersions('/facebook/react'); + expect(versions).toContain('v18.3.0'); + expect(versions).toContain('v17.0.2'); + }); +}); + +// --------------------------------------------------------------------------- +// createIndexingJob() +// --------------------------------------------------------------------------- + +describe('RepositoryService.createIndexingJob()', () => { + let service: RepositoryService; + + beforeEach(() => { + service = makeService(createTestDb()); + service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + }); + + it('creates a queued indexing job', () => { + const job = service.createIndexingJob('/facebook/react') as unknown as RawJob; + expect(job.id).toBeTruthy(); + expect(job.repository_id).toBe('/facebook/react'); + expect(job.status).toBe('queued'); + expect(job.progress).toBe(0); + }); + + it('returns the existing job when one is already queued', () => { + const job1 = service.createIndexingJob('/facebook/react'); + const job2 = service.createIndexingJob('/facebook/react'); + expect(job2.id).toBe(job1.id); + }); + + it('creates a new job once the previous job is done', () => { + const job1 = service.createIndexingJob('/facebook/react'); + // Access the underlying db to simulate job completion. + const db = (service as unknown as { db: Database.Database }).db; + db.prepare(`UPDATE indexing_jobs SET status = 'done' WHERE id = ?`).run(job1.id); + + const job2 = service.createIndexingJob('/facebook/react'); + expect(job2.id).not.toBe(job1.id); + }); + + it('accepts an optional versionId', () => { + const job = service.createIndexingJob( + '/facebook/react', + '/facebook/react/v18.3.0' + ) as unknown as RawJob; + expect(job.version_id).toBe('/facebook/react/v18.3.0'); + }); +}); diff --git a/src/lib/server/services/repository.service.ts b/src/lib/server/services/repository.service.ts new file mode 100644 index 0000000..823bcb6 --- /dev/null +++ b/src/lib/server/services/repository.service.ts @@ -0,0 +1,321 @@ +/** + * 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; + } +} diff --git a/src/lib/server/utils/id-resolver.ts b/src/lib/server/utils/id-resolver.ts new file mode 100644 index 0000000..f024c88 --- /dev/null +++ b/src/lib/server/utils/id-resolver.ts @@ -0,0 +1,42 @@ +/** + * Repository ID generation utilities. + */ + +/** + * Parse a GitHub URL into a canonical repository ID. + * Supports: + * https://github.com/facebook/react + * https://github.com/facebook/react.git + * github.com/facebook/react + */ +export function resolveGitHubId(url: string): string { + const match = url.match(/github\.com\/([^/]+)\/([^/\s.]+)/); + if (!match) throw new Error('Invalid GitHub URL — expected github.com/owner/repo'); + return `/${match[1]}/${match[2]}`; +} + +/** + * Generate a local repository ID from an absolute path. + * Collision-resolves by appending -2, -3, etc. + */ +export function resolveLocalId(path: string, existingIds: string[]): string { + const base = slugify(path.split('/').at(-1) ?? 'repo'); + let id = `/local/${base}`; + let counter = 2; + while (existingIds.includes(id)) { + id = `/local/${base}-${counter++}`; + } + return id; +} + +/** + * Slugify a string to be safe for use in IDs. + */ +function slugify(str: string): string { + return str + .toLowerCase() + .replace(/[^a-z0-9-_]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + || 'repo'; +} diff --git a/src/lib/server/utils/validation.ts b/src/lib/server/utils/validation.ts new file mode 100644 index 0000000..684286d --- /dev/null +++ b/src/lib/server/utils/validation.ts @@ -0,0 +1,94 @@ +/** + * Input validation helpers for the REST API. + */ + +export interface ValidationError { + field: string; + message: string; +} + +export class InvalidInputError extends Error { + readonly code = 'INVALID_INPUT'; + constructor( + message: string, + public readonly errors: ValidationError[] = [] + ) { + super(message); + this.name = 'InvalidInputError'; + } +} + +export class NotFoundError extends Error { + readonly code = 'NOT_FOUND'; + constructor(message: string) { + super(message); + this.name = 'NotFoundError'; + } +} + +export class AlreadyExistsError extends Error { + readonly code = 'ALREADY_EXISTS'; + constructor(message: string) { + super(message); + this.name = 'AlreadyExistsError'; + } +} + +export class IndexingInProgressError extends Error { + readonly code = 'INDEXING_IN_PROGRESS'; + constructor(message: string) { + super(message); + this.name = 'IndexingInProgressError'; + } +} + +export class InvalidUrlError extends Error { + readonly code = 'INVALID_URL'; + constructor(message: string) { + super(message); + this.name = 'InvalidUrlError'; + } +} + +/** + * Build a standard JSON error response body. + */ +export function errorResponse( + error: string, + code: string, + status: number, + details?: Record +): Response { + return new Response( + JSON.stringify({ error, code, ...(details ? { details } : {}) }), + { + status, + headers: { 'Content-Type': 'application/json' } + } + ); +} + +/** + * Convert a known error type to an HTTP response. + */ +export function handleServiceError(err: unknown): Response { + if (err instanceof NotFoundError) { + return errorResponse(err.message, err.code, 404); + } + if (err instanceof AlreadyExistsError) { + return errorResponse(err.message, err.code, 409); + } + if (err instanceof InvalidInputError) { + return errorResponse(err.message, err.code, 400, { + errors: err.errors + }); + } + if (err instanceof InvalidUrlError) { + return errorResponse(err.message, err.code, 400); + } + if (err instanceof IndexingInProgressError) { + return errorResponse(err.message, err.code, 409); + } + const message = err instanceof Error ? err.message : 'Internal server error'; + return errorResponse(message, 'INTERNAL_ERROR', 500); +} diff --git a/src/routes/api/v1/libs/+server.ts b/src/routes/api/v1/libs/+server.ts new file mode 100644 index 0000000..a2e3809 --- /dev/null +++ b/src/routes/api/v1/libs/+server.ts @@ -0,0 +1,84 @@ +/** + * GET /api/v1/libs — list all repositories + * POST /api/v1/libs — add a new repository + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getClient } from '$lib/server/db/client'; +import { RepositoryService } from '$lib/server/services/repository.service'; +import { handleServiceError } from '$lib/server/utils/validation'; + +function getService() { + return new RepositoryService(getClient()); +} + +export const GET: RequestHandler = ({ url }) => { + try { + const service = getService(); + const state = url.searchParams.get('state') as + | 'pending' + | 'indexing' + | 'indexed' + | 'error' + | undefined; + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50'), 200); + const offset = parseInt(url.searchParams.get('offset') ?? '0'); + + const libraries = service.list({ state: state ?? undefined, limit, offset }); + const total = service.count(state ?? undefined); + + // Augment each library with its versions array + const enriched = libraries.map((repo) => ({ + ...repo, + versions: service.getVersions(repo.id) + })); + + return json({ libraries: enriched, total, limit, offset }); + } catch (err) { + return handleServiceError(err); + } +}; + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const service = getService(); + + const repo = service.add({ + source: body.source, + sourceUrl: body.sourceUrl, + title: body.title, + description: body.description, + branch: body.branch, + githubToken: body.githubToken + }); + + let jobResponse: { id: string; status: string } | null = null; + if (body.autoIndex !== false) { + const job = service.createIndexingJob(repo.id); + jobResponse = { id: job.id, status: job.status }; + } + + return json( + { library: repo, ...(jobResponse ? { job: jobResponse } : {}) }, + { status: 201 } + ); + } catch (err) { + return handleServiceError(err); + } +}; + +export const OPTIONS: RequestHandler = () => { + return new Response(null, { + status: 204, + headers: corsHeaders() + }); +}; + +function corsHeaders(): Record { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + }; +} diff --git a/src/routes/api/v1/libs/[id]/+server.ts b/src/routes/api/v1/libs/[id]/+server.ts new file mode 100644 index 0000000..8934b1e --- /dev/null +++ b/src/routes/api/v1/libs/[id]/+server.ts @@ -0,0 +1,68 @@ +/** + * GET /api/v1/libs/:id — get a single repository + * PATCH /api/v1/libs/:id — update repository metadata + * DELETE /api/v1/libs/:id — delete a repository + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getClient } from '$lib/server/db/client'; +import { RepositoryService } from '$lib/server/services/repository.service'; +import { handleServiceError } from '$lib/server/utils/validation'; + +function getService() { + return new RepositoryService(getClient()); +} + +export const GET: RequestHandler = ({ params }) => { + try { + const service = getService(); + const id = decodeURIComponent(params.id); + const repo = service.get(id); + if (!repo) { + return json({ error: 'Repository not found', code: 'NOT_FOUND' }, { status: 404 }); + } + const versions = service.getVersions(id); + return json({ ...repo, versions }); + } catch (err) { + return handleServiceError(err); + } +}; + +export const PATCH: RequestHandler = async ({ params, request }) => { + try { + const service = getService(); + const id = decodeURIComponent(params.id); + const body = await request.json(); + const updated = service.update(id, { + title: body.title, + description: body.description, + branch: body.branch, + githubToken: body.githubToken + }); + return json(updated); + } catch (err) { + return handleServiceError(err); + } +}; + +export const DELETE: RequestHandler = ({ params }) => { + try { + const service = getService(); + const id = decodeURIComponent(params.id); + service.remove(id); + return new Response(null, { status: 204 }); + } catch (err) { + return handleServiceError(err); + } +}; + +export const OPTIONS: RequestHandler = () => { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + } + }); +}; diff --git a/src/routes/api/v1/libs/[id]/index/+server.ts b/src/routes/api/v1/libs/[id]/index/+server.ts new file mode 100644 index 0000000..e673f33 --- /dev/null +++ b/src/routes/api/v1/libs/[id]/index/+server.ts @@ -0,0 +1,43 @@ +/** + * POST /api/v1/libs/:id/index — trigger an indexing job for a repository. + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getClient } from '$lib/server/db/client'; +import { RepositoryService } from '$lib/server/services/repository.service'; +import { handleServiceError, NotFoundError } from '$lib/server/utils/validation'; + +export const POST: RequestHandler = async ({ params, request }) => { + try { + const service = new RepositoryService(getClient()); + const id = decodeURIComponent(params.id); + + const repo = service.get(id); + if (!repo) throw new NotFoundError(`Repository ${id} not found`); + + let versionId: string | undefined; + try { + const body = await request.json(); + versionId = body.version ?? undefined; + } catch { + // body is optional + } + + const job = service.createIndexingJob(id, versionId); + + return json({ job }, { status: 202 }); + } catch (err) { + return handleServiceError(err); + } +}; + +export const OPTIONS: RequestHandler = () => { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + } + }); +};