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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user