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 <noreply@anthropic.com>
This commit is contained in:
18
src/lib/server/db/client.ts
Normal file
18
src/lib/server/db/client.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
476
src/lib/server/services/repository.service.test.ts
Normal file
476
src/lib/server/services/repository.service.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
321
src/lib/server/services/repository.service.ts
Normal file
321
src/lib/server/services/repository.service.ts
Normal file
@@ -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<string, unknown> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/lib/server/utils/id-resolver.ts
Normal file
42
src/lib/server/utils/id-resolver.ts
Normal file
@@ -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';
|
||||||
|
}
|
||||||
94
src/lib/server/utils/validation.ts
Normal file
94
src/lib/server/utils/validation.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
): 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);
|
||||||
|
}
|
||||||
84
src/routes/api/v1/libs/+server.ts
Normal file
84
src/routes/api/v1/libs/+server.ts
Normal file
@@ -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<string, string> {
|
||||||
|
return {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||||
|
};
|
||||||
|
}
|
||||||
68
src/routes/api/v1/libs/[id]/+server.ts
Normal file
68
src/routes/api/v1/libs/[id]/+server.ts
Normal file
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
43
src/routes/api/v1/libs/[id]/index/+server.ts
Normal file
43
src/routes/api/v1/libs/[id]/index/+server.ts
Normal file
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user