Files
trueref/src/lib/server/services/repository.service.ts
Giancarmine Salucci 3d1bef5003 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>
2026-03-22 17:43:06 +01:00

322 lines
8.2 KiB
TypeScript

/**
* 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;
}
}