From 542f4ce66c33383b1e648c96786bd46787a6f14e Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Mon, 23 Mar 2026 09:06:59 +0100 Subject: [PATCH] feat(TRUEREF-0014): implement repository version management - VersionService with list, add, remove, getByTag, registerFromConfig - GitHub tag discovery helper for validating tags before indexing - Version ID format: /owner/repo/tag (e.g. /facebook/react/v18.3.0) - GET/POST /api/v1/libs/:id/versions - DELETE /api/v1/libs/:id/versions/:tag - POST /api/v1/libs/:id/versions/:tag/index Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/crawler/github-tags.test.ts | 110 +++++++ src/lib/server/crawler/github-tags.ts | 59 ++++ .../server/services/version.service.test.ts | 275 ++++++++++++++++++ src/lib/server/services/version.service.ts | 134 +++++++++ .../api/v1/libs/[id]/versions/+server.ts | 102 +++++++ .../v1/libs/[id]/versions/[tag]/+server.ts | 54 ++++ .../libs/[id]/versions/[tag]/index/+server.ts | 60 ++++ 7 files changed, 794 insertions(+) create mode 100644 src/lib/server/crawler/github-tags.test.ts create mode 100644 src/lib/server/crawler/github-tags.ts create mode 100644 src/lib/server/services/version.service.test.ts create mode 100644 src/lib/server/services/version.service.ts create mode 100644 src/routes/api/v1/libs/[id]/versions/+server.ts create mode 100644 src/routes/api/v1/libs/[id]/versions/[tag]/+server.ts create mode 100644 src/routes/api/v1/libs/[id]/versions/[tag]/index/+server.ts diff --git a/src/lib/server/crawler/github-tags.test.ts b/src/lib/server/crawler/github-tags.test.ts new file mode 100644 index 0000000..719d674 --- /dev/null +++ b/src/lib/server/crawler/github-tags.test.ts @@ -0,0 +1,110 @@ +/** + * Unit tests for github-tags helper. + * + * External HTTP calls are mocked via vitest's global fetch stub so the tests + * run fully offline. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { listGitHubTags, findGitHubTag, GitHubApiError } from './github-tags'; + +const MOCK_TAGS = [ + { name: 'v18.3.0', commit: { sha: 'abc123' } }, + { name: 'v17.0.2', commit: { sha: 'def456' } } +]; + +function mockFetch(status: number, body: unknown) { + return vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body) + }); +} + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch(200, MOCK_TAGS)); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// listGitHubTags() +// --------------------------------------------------------------------------- + +describe('listGitHubTags()', () => { + it('returns parsed tag list on success', async () => { + const tags = await listGitHubTags('facebook', 'react'); + expect(tags).toHaveLength(2); + expect(tags[0].name).toBe('v18.3.0'); + expect(tags[0].commit.sha).toBe('abc123'); + }); + + it('calls the correct GitHub API URL', async () => { + await listGitHubTags('facebook', 'react'); + const [url] = (fetch as ReturnType).mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.github.com/repos/facebook/react/tags?per_page=100'); + }); + + it('includes Accept and User-Agent headers', async () => { + await listGitHubTags('facebook', 'react'); + const [, init] = (fetch as ReturnType).mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['Accept']).toBe('application/vnd.github.v3+json'); + expect(headers['User-Agent']).toBe('TrueRef/1.0'); + }); + + it('includes Authorization header when token is provided', async () => { + await listGitHubTags('facebook', 'react', 'mytoken'); + const [, init] = (fetch as ReturnType).mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['Authorization']).toBe('Bearer mytoken'); + }); + + it('does not include Authorization header when token is absent', async () => { + await listGitHubTags('facebook', 'react'); + const [, init] = (fetch as ReturnType).mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['Authorization']).toBeUndefined(); + }); + + it('throws GitHubApiError on non-2xx response', async () => { + vi.stubGlobal('fetch', mockFetch(404, { message: 'Not Found' })); + await expect(listGitHubTags('facebook', 'react')).rejects.toThrow(GitHubApiError); + }); + + it('GitHubApiError carries the HTTP status code', async () => { + vi.stubGlobal('fetch', mockFetch(403, {})); + try { + await listGitHubTags('facebook', 'react'); + } catch (err) { + expect(err).toBeInstanceOf(GitHubApiError); + expect((err as GitHubApiError).status).toBe(403); + } + }); +}); + +// --------------------------------------------------------------------------- +// findGitHubTag() +// --------------------------------------------------------------------------- + +describe('findGitHubTag()', () => { + it('returns the tag record when it exists', async () => { + const tag = await findGitHubTag('facebook', 'react', 'v18.3.0'); + expect(tag).not.toBeNull(); + expect(tag?.name).toBe('v18.3.0'); + }); + + it('returns null when the tag does not exist', async () => { + const tag = await findGitHubTag('facebook', 'react', 'v99.0.0'); + expect(tag).toBeNull(); + }); + + it('passes the token to listGitHubTags', async () => { + await findGitHubTag('facebook', 'react', 'v18.3.0', 'mytoken'); + const [, init] = (fetch as ReturnType).mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['Authorization']).toBe('Bearer mytoken'); + }); +}); diff --git a/src/lib/server/crawler/github-tags.ts b/src/lib/server/crawler/github-tags.ts new file mode 100644 index 0000000..985bcc4 --- /dev/null +++ b/src/lib/server/crawler/github-tags.ts @@ -0,0 +1,59 @@ +/** + * GitHub tag discovery helper for TRUEREF-0014. + * + * Fetches the list of git tags for a GitHub repository, used to validate + * that a requested tag exists before queuing an indexing job. + */ + +export interface GitHubTag { + name: string; + commit: { sha: string }; +} + +export class GitHubApiError extends Error { + constructor(public readonly status: number) { + super(`GitHub API error: HTTP ${status}`); + this.name = 'GitHubApiError'; + } +} + +/** + * List up to 100 tags for a GitHub repository. + * + * @param owner GitHub owner/organisation name + * @param repo GitHub repository name + * @param token Optional personal access token for private repos / rate-limit relief + */ +export async function listGitHubTags( + owner: string, + repo: string, + token?: string +): Promise { + const headers: Record = { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'TrueRef/1.0' + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/tags?per_page=100`, + { headers } + ); + + if (!response.ok) throw new GitHubApiError(response.status); + return response.json() as Promise; +} + +/** + * Check whether a specific tag name exists in the remote repository. + * Returns the matching tag record, or `null` if absent. + */ +export async function findGitHubTag( + owner: string, + repo: string, + tag: string, + token?: string +): Promise { + const tags = await listGitHubTags(owner, repo, token); + return tags.find((t) => t.name === tag) ?? null; +} diff --git a/src/lib/server/services/version.service.test.ts b/src/lib/server/services/version.service.test.ts new file mode 100644 index 0000000..9993f6a --- /dev/null +++ b/src/lib/server/services/version.service.test.ts @@ -0,0 +1,275 @@ +/** + * Unit tests for VersionService. + * + * Uses an in-memory SQLite database seeded from the canonical migration file. + * Returned rows have snake_case column names matching the raw better-sqlite3 + * output — the same pattern used by repository.service.test.ts. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { VersionService } from './version.service'; +import { RepositoryService } from './repository.service'; +import { AlreadyExistsError, 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' + ); + + 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 repository_versions. +interface RawVersion { + id: string; + repository_id: string; + tag: string; + title: string | null; + state: string; + total_snippets: number; + indexed_at: number | null; + created_at: number; +} + +function setup() { + const client = createTestDb(); + const repoService = new RepositoryService(client); + const versionService = new VersionService(client); + + // Add a parent repository used across most tests. + repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + + return { client, repoService, versionService }; +} + +// --------------------------------------------------------------------------- +// list() +// --------------------------------------------------------------------------- + +describe('VersionService.list()', () => { + it('returns an empty array when no versions exist', () => { + const { versionService } = setup(); + expect(versionService.list('/facebook/react')).toEqual([]); + }); + + it('returns all versions for a repository', () => { + const { versionService } = setup(); + versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0'); + versionService.add('/facebook/react', 'v17.0.2', 'React v17.0.2'); + const versions = versionService.list('/facebook/react'); + expect(versions).toHaveLength(2); + }); + + it('returns newest versions first', () => { + const { client, versionService } = setup(); + const now = Math.floor(Date.now() / 1000); + // Insert with explicit timestamps to guarantee ordering. + client + .prepare( + `INSERT INTO repository_versions (id, repository_id, tag, state, total_snippets, created_at) + VALUES (?, '/facebook/react', ?, 'pending', 0, ?)` + ) + .run('/facebook/react/v17.0.2', 'v17.0.2', now - 10); + client + .prepare( + `INSERT INTO repository_versions (id, repository_id, tag, state, total_snippets, created_at) + VALUES (?, '/facebook/react', ?, 'pending', 0, ?)` + ) + .run('/facebook/react/v18.3.0', 'v18.3.0', now); + const versions = versionService.list('/facebook/react') as unknown as RawVersion[]; + // Newest created_at first. + expect(versions[0].tag).toBe('v18.3.0'); + }); + + it('returns empty array for an unknown repository (no FK check on list)', () => { + const { versionService } = setup(); + expect(versionService.list('/unknown/repo')).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// add() +// --------------------------------------------------------------------------- + +describe('VersionService.add()', () => { + it('creates a version with the correct ID format', () => { + const { versionService } = setup(); + const version = versionService.add( + '/facebook/react', + 'v18.3.0', + 'React v18.3.0' + ) as unknown as RawVersion; + expect(version.id).toBe('/facebook/react/v18.3.0'); + expect(version.repository_id).toBe('/facebook/react'); + expect(version.tag).toBe('v18.3.0'); + expect(version.title).toBe('React v18.3.0'); + expect(version.state).toBe('pending'); + expect(version.total_snippets).toBe(0); + expect(version.indexed_at).toBeNull(); + }); + + it('creates a version without a title', () => { + const { versionService } = setup(); + const version = versionService.add( + '/facebook/react', + 'v18.3.0' + ) as unknown as RawVersion; + expect(version.title).toBeNull(); + }); + + it('throws NotFoundError when the parent repository does not exist', () => { + const { versionService } = setup(); + expect(() => versionService.add('/unknown/repo', 'v1.0.0')).toThrow(NotFoundError); + }); + + it('throws AlreadyExistsError when adding a duplicate tag', () => { + const { versionService } = setup(); + versionService.add('/facebook/react', 'v18.3.0'); + expect(() => versionService.add('/facebook/react', 'v18.3.0')).toThrow(AlreadyExistsError); + }); + + it('allows the same tag for different repositories', () => { + const { repoService, versionService } = setup(); + // Use a repo name without dots so resolveGitHubId produces a predictable ID. + repoService.add({ source: 'github', sourceUrl: 'https://github.com/vercel/nextjs' }); + versionService.add('/facebook/react', 'v18.3.0'); + const v = versionService.add('/vercel/nextjs', 'v18.3.0') as unknown as RawVersion; + expect(v.id).toBe('/vercel/nextjs/v18.3.0'); + }); +}); + +// --------------------------------------------------------------------------- +// remove() +// --------------------------------------------------------------------------- + +describe('VersionService.remove()', () => { + it('removes an existing version', () => { + const { versionService } = setup(); + versionService.add('/facebook/react', 'v18.3.0'); + versionService.remove('/facebook/react', 'v18.3.0'); + expect(versionService.getByTag('/facebook/react', 'v18.3.0')).toBeNull(); + }); + + it('throws NotFoundError when the version does not exist', () => { + const { versionService } = setup(); + expect(() => versionService.remove('/facebook/react', 'v99.0.0')).toThrow(NotFoundError); + }); + + it('cascades to documents and snippets on delete', () => { + const { client, versionService } = setup(); + versionService.add('/facebook/react', 'v18.3.0'); + + const now = Math.floor(Date.now() / 1000); + const docId = crypto.randomUUID(); + client + .prepare( + `INSERT INTO documents (id, repository_id, version_id, file_path, checksum, indexed_at) + VALUES (?, '/facebook/react', '/facebook/react/v18.3.0', 'README.md', 'abc', ?)` + ) + .run(docId, now); + + versionService.remove('/facebook/react', 'v18.3.0'); + + const doc = client + .prepare(`SELECT id FROM documents WHERE id = ?`) + .get(docId); + expect(doc).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// getByTag() +// --------------------------------------------------------------------------- + +describe('VersionService.getByTag()', () => { + it('returns null when the version does not exist', () => { + const { versionService } = setup(); + expect(versionService.getByTag('/facebook/react', 'v99.0.0')).toBeNull(); + }); + + it('returns the version record when it exists', () => { + const { versionService } = setup(); + versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0'); + const version = versionService.getByTag( + '/facebook/react', + 'v18.3.0' + ) as unknown as RawVersion; + expect(version).not.toBeNull(); + expect(version.tag).toBe('v18.3.0'); + expect(version.repository_id).toBe('/facebook/react'); + }); +}); + +// --------------------------------------------------------------------------- +// registerFromConfig() +// --------------------------------------------------------------------------- + +describe('VersionService.registerFromConfig()', () => { + it('registers multiple versions from config', () => { + const { versionService } = setup(); + const result = versionService.registerFromConfig('/facebook/react', [ + { tag: 'v18.3.0', title: 'React v18.3.0' }, + { tag: 'v17.0.2', title: 'React v17.0.2' } + ]); + expect(result).toHaveLength(2); + expect(versionService.list('/facebook/react')).toHaveLength(2); + }); + + it('skips already-registered tags idempotently', () => { + const { versionService } = setup(); + versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0'); + const result = versionService.registerFromConfig('/facebook/react', [ + { tag: 'v18.3.0', title: 'React v18.3.0 (duplicate)' }, + { tag: 'v17.0.2', title: 'React v17.0.2' } + ]); + // Both entries are returned but only one new row is created. + expect(result).toHaveLength(2); + expect(versionService.list('/facebook/react')).toHaveLength(2); + }); + + it('returns an empty array when given an empty previousVersions list', () => { + const { versionService } = setup(); + const result = versionService.registerFromConfig('/facebook/react', []); + expect(result).toEqual([]); + }); + + it('throws NotFoundError when the parent repository does not exist', () => { + const { versionService } = setup(); + expect(() => + versionService.registerFromConfig('/unknown/repo', [{ tag: 'v1.0.0', title: 'v1' }]) + ).toThrow(NotFoundError); + }); + + it('sets all registered versions to state pending', () => { + const { versionService } = setup(); + versionService.registerFromConfig('/facebook/react', [ + { tag: 'v18.3.0', title: 'React v18.3.0' } + ]); + const version = versionService.getByTag( + '/facebook/react', + 'v18.3.0' + ) as unknown as RawVersion; + expect(version.state).toBe('pending'); + }); +}); diff --git a/src/lib/server/services/version.service.ts b/src/lib/server/services/version.service.ts new file mode 100644 index 0000000..c45d5a7 --- /dev/null +++ b/src/lib/server/services/version.service.ts @@ -0,0 +1,134 @@ +/** + * VersionService — CRUD operations for repository_versions. + * + * Operates directly on the raw better-sqlite3 client for synchronous queries, + * matching the pattern used by RepositoryService. + */ + +import type Database from 'better-sqlite3'; +import type { RepositoryVersion } from '$lib/types'; +import { AlreadyExistsError, NotFoundError } from '$lib/server/utils/validation'; + +export class VersionService { + constructor(private readonly db: Database.Database) {} + + /** + * List all versions for a repository, newest first. + */ + list(repositoryId: string): RepositoryVersion[] { + return this.db + .prepare( + `SELECT * FROM repository_versions + WHERE repository_id = ? + ORDER BY created_at DESC` + ) + .all(repositoryId) as RepositoryVersion[]; + } + + /** + * Add a new version record for a repository. + * The version ID follows the convention: {repositoryId}/{tag} + * + * @throws NotFoundError when the parent repository does not exist + * @throws AlreadyExistsError when the tag is already registered + */ + add(repositoryId: string, tag: string, title?: string): RepositoryVersion { + // Verify parent repository exists. + const repo = this.db + .prepare(`SELECT id FROM repositories WHERE id = ?`) + .get(repositoryId) as { id: string } | undefined; + + if (!repo) { + throw new NotFoundError(`Repository ${repositoryId} not found`); + } + + const id = `${repositoryId}/${tag}`; + + // Check for collision. + const existing = this.getByTag(repositoryId, tag); + if (existing) { + throw new AlreadyExistsError(`Version ${tag} already exists for repository ${repositoryId}`); + } + + const now = Math.floor(Date.now() / 1000); + + this.db + .prepare( + `INSERT INTO repository_versions + (id, repository_id, tag, title, state, total_snippets, indexed_at, created_at) + VALUES (?, ?, ?, ?, 'pending', 0, NULL, ?)` + ) + .run(id, repositoryId, tag, title ?? null, now); + + return this.db + .prepare(`SELECT * FROM repository_versions WHERE id = ?`) + .get(id) as RepositoryVersion; + } + + /** + * Delete a version and all associated documents / snippets (cascades via FK). + * + * @throws NotFoundError when the version does not exist + */ + remove(repositoryId: string, tag: string): void { + const version = this.getByTag(repositoryId, tag); + if (!version) { + throw new NotFoundError(`Version ${tag} not found for repository ${repositoryId}`); + } + + this.db + .prepare(`DELETE FROM repository_versions WHERE repository_id = ? AND tag = ?`) + .run(repositoryId, tag); + } + + /** + * Get a single version by tag. + * Returns `null` when not found. + */ + getByTag(repositoryId: string, tag: string): RepositoryVersion | null { + return ( + (this.db + .prepare( + `SELECT * FROM repository_versions WHERE repository_id = ? AND tag = ?` + ) + .get(repositoryId, tag) as RepositoryVersion | undefined) ?? null + ); + } + + /** + * Register multiple versions from a trueref.json `previousVersions` array. + * Silently skips tags that are already registered (idempotent). + * All new records are created with state = 'pending'. + * + * @throws NotFoundError when the parent repository does not exist + */ + registerFromConfig( + repositoryId: string, + previousVersions: { tag: string; title: string }[] + ): RepositoryVersion[] { + // Verify parent repository exists. + const repo = this.db + .prepare(`SELECT id FROM repositories WHERE id = ?`) + .get(repositoryId) as { id: string } | undefined; + + if (!repo) { + throw new NotFoundError(`Repository ${repositoryId} not found`); + } + + const registered: RepositoryVersion[] = []; + + for (const { tag, title } of previousVersions) { + const existing = this.getByTag(repositoryId, tag); + if (existing) { + // Already registered — skip silently. + registered.push(existing); + continue; + } + + const version = this.add(repositoryId, tag, title); + registered.push(version); + } + + return registered; + } +} diff --git a/src/routes/api/v1/libs/[id]/versions/+server.ts b/src/routes/api/v1/libs/[id]/versions/+server.ts new file mode 100644 index 0000000..2312429 --- /dev/null +++ b/src/routes/api/v1/libs/[id]/versions/+server.ts @@ -0,0 +1,102 @@ +/** + * GET /api/v1/libs/:id/versions — list all indexed versions for a repository + * POST /api/v1/libs/:id/versions — add a new version (tag or branch) + */ + +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 { VersionService } from '$lib/server/services/version.service'; +import { handleServiceError, NotFoundError, InvalidInputError } from '$lib/server/utils/validation'; + +function getServices() { + const db = getClient(); + return { + repoService: new RepositoryService(db), + versionService: new VersionService(db) + }; +} + +// --------------------------------------------------------------------------- +// GET /api/v1/libs/:id/versions +// --------------------------------------------------------------------------- + +export const GET: RequestHandler = ({ params }) => { + try { + const { repoService, versionService } = getServices(); + const repositoryId = decodeURIComponent(params.id); + + const repo = repoService.get(repositoryId); + if (!repo) { + throw new NotFoundError(`Repository ${repositoryId} not found`); + } + + const versions = versionService.list(repositoryId); + return json({ versions }); + } catch (err) { + return handleServiceError(err); + } +}; + +// --------------------------------------------------------------------------- +// POST /api/v1/libs/:id/versions +// --------------------------------------------------------------------------- + +export const POST: RequestHandler = async ({ params, request }) => { + try { + const { repoService, versionService } = getServices(); + const repositoryId = decodeURIComponent(params.id); + + const repo = repoService.get(repositoryId); + if (!repo) { + throw new NotFoundError(`Repository ${repositoryId} not found`); + } + + let body: { tag?: unknown; title?: unknown; autoIndex?: unknown }; + try { + body = await request.json(); + } catch { + throw new InvalidInputError('Request body must be valid JSON', [ + { field: 'body', message: 'Invalid JSON' } + ]); + } + + const tag = body.tag; + if (!tag || typeof tag !== 'string' || !tag.trim()) { + throw new InvalidInputError('tag is required', [ + { field: 'tag', message: 'tag must be a non-empty string' } + ]); + } + + const title = typeof body.title === 'string' ? body.title : undefined; + const autoIndex = body.autoIndex === true; + + const version = versionService.add(repositoryId, tag.trim(), title); + + let job: { id: string; status: string } | undefined; + if (autoIndex) { + const indexingJob = repoService.createIndexingJob(repositoryId, version.id); + job = { id: indexingJob.id, status: indexingJob.status }; + } + + return json({ version, ...(job ? { job } : {}) }, { status: 201 }); + } catch (err) { + return handleServiceError(err); + } +}; + +// --------------------------------------------------------------------------- +// OPTIONS preflight +// --------------------------------------------------------------------------- + +export const OPTIONS: RequestHandler = () => { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + } + }); +}; diff --git a/src/routes/api/v1/libs/[id]/versions/[tag]/+server.ts b/src/routes/api/v1/libs/[id]/versions/[tag]/+server.ts new file mode 100644 index 0000000..cca215b --- /dev/null +++ b/src/routes/api/v1/libs/[id]/versions/[tag]/+server.ts @@ -0,0 +1,54 @@ +/** + * DELETE /api/v1/libs/:id/versions/:tag — remove a version and all its snippets + */ + +import type { RequestHandler } from './$types'; +import { getClient } from '$lib/server/db/client'; +import { RepositoryService } from '$lib/server/services/repository.service'; +import { VersionService } from '$lib/server/services/version.service'; +import { handleServiceError, NotFoundError } from '$lib/server/utils/validation'; + +function getServices() { + const db = getClient(); + return { + repoService: new RepositoryService(db), + versionService: new VersionService(db) + }; +} + +// --------------------------------------------------------------------------- +// DELETE /api/v1/libs/:id/versions/:tag +// --------------------------------------------------------------------------- + +export const DELETE: RequestHandler = ({ params }) => { + try { + const { repoService, versionService } = getServices(); + const repositoryId = decodeURIComponent(params.id); + const tag = decodeURIComponent(params.tag); + + const repo = repoService.get(repositoryId); + if (!repo) { + throw new NotFoundError(`Repository ${repositoryId} not found`); + } + + versionService.remove(repositoryId, tag); + return new Response(null, { status: 204 }); + } catch (err) { + return handleServiceError(err); + } +}; + +// --------------------------------------------------------------------------- +// OPTIONS preflight +// --------------------------------------------------------------------------- + +export const OPTIONS: RequestHandler = () => { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + } + }); +}; diff --git a/src/routes/api/v1/libs/[id]/versions/[tag]/index/+server.ts b/src/routes/api/v1/libs/[id]/versions/[tag]/index/+server.ts new file mode 100644 index 0000000..0016e67 --- /dev/null +++ b/src/routes/api/v1/libs/[id]/versions/[tag]/index/+server.ts @@ -0,0 +1,60 @@ +/** + * POST /api/v1/libs/:id/versions/:tag/index — queue an indexing job for a specific version + */ + +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 { VersionService } from '$lib/server/services/version.service'; +import { handleServiceError, NotFoundError } from '$lib/server/utils/validation'; + +function getServices() { + const db = getClient(); + return { + repoService: new RepositoryService(db), + versionService: new VersionService(db) + }; +} + +// --------------------------------------------------------------------------- +// POST /api/v1/libs/:id/versions/:tag/index +// --------------------------------------------------------------------------- + +export const POST: RequestHandler = ({ params }) => { + try { + const { repoService, versionService } = getServices(); + const repositoryId = decodeURIComponent(params.id); + const tag = decodeURIComponent(params.tag); + + const repo = repoService.get(repositoryId); + if (!repo) { + throw new NotFoundError(`Repository ${repositoryId} not found`); + } + + const version = versionService.getByTag(repositoryId, tag); + if (!version) { + throw new NotFoundError(`Version ${tag} not found for repository ${repositoryId}`); + } + + const job = repoService.createIndexingJob(repositoryId, version.id); + return json({ job }, { status: 202 }); + } catch (err) { + return handleServiceError(err); + } +}; + +// --------------------------------------------------------------------------- +// OPTIONS preflight +// --------------------------------------------------------------------------- + +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' + } + }); +};