/** * 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 { RepositoryVersionMapper } from '$lib/server/mappers/repository-version.mapper.js'; import { RepositoryVersion, RepositoryVersionEntity } from '$lib/server/models/repository-version.js'; import { AlreadyExistsError, NotFoundError } from '$lib/server/utils/validation'; import { resolveTagToCommit, discoverVersionTags } from '$lib/server/utils/git.js'; export class VersionService { constructor(private readonly db: Database.Database) {} /** * List all versions for a repository, newest first. */ list(repositoryId: string): RepositoryVersion[] { const rows = this.db .prepare( `SELECT * FROM repository_versions WHERE repository_id = ? ORDER BY created_at DESC` ) .all(repositoryId) as RepositoryVersionEntity[]; return rows.map((row) => RepositoryVersionMapper.fromEntity(new RepositoryVersionEntity(row))); } /** * Add a new version record for a repository. * The version ID follows the convention: {repositoryId}/{tag} * * @param commitHash Optional commit hash. If not provided and repository is local, * will attempt to resolve the tag to a commit hash automatically. * * @throws NotFoundError when the parent repository does not exist * @throws AlreadyExistsError when the tag is already registered */ add(repositoryId: string, tag: string, title?: string, commitHash?: string): RepositoryVersion { // Verify parent repository exists. const repo = this.db .prepare(`SELECT id, source, source_url FROM repositories WHERE id = ?`) .get(repositoryId) as { id: string; source: string; source_url: 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}`); } // For local repositories, attempt to resolve tag to commit hash if not provided let resolvedCommitHash = commitHash; if (!resolvedCommitHash && repo.source === 'local') { try { resolvedCommitHash = resolveTagToCommit({ repoPath: repo.source_url, tag }); } catch (error) { console.warn( `[VersionService] Could not resolve tag '${tag}' to commit hash for ${repositoryId}: ${error instanceof Error ? error.message : String(error)}` ); // Continue without commit hash — non-blocking } } const now = Math.floor(Date.now() / 1000); this.db .prepare( `INSERT INTO repository_versions (id, repository_id, tag, title, commit_hash, state, total_snippets, indexed_at, created_at) VALUES (?, ?, ?, ?, ?, 'pending', 0, NULL, ?)` ) .run(id, repositoryId, tag, title ?? null, resolvedCommitHash ?? null, now); const row = this.db .prepare(`SELECT * FROM repository_versions WHERE id = ?`) .get(id) as RepositoryVersionEntity; return RepositoryVersionMapper.fromEntity(new RepositoryVersionEntity(row)); } /** * 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 { const row = this.db .prepare(`SELECT * FROM repository_versions WHERE repository_id = ? AND tag = ?`) .get(repositoryId, tag) as RepositoryVersionEntity | undefined; return row ? RepositoryVersionMapper.fromEntity(new RepositoryVersionEntity(row)) : 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'. * * Supports optional `commitHash` field to pin a version to a specific commit, * overriding tag resolution (TRUEREF-0019). * * @throws NotFoundError when the parent repository does not exist */ registerFromConfig( repositoryId: string, previousVersions: { tag: string; title: string; commitHash?: 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, commitHash } 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, commitHash); registered.push(version); } return registered; } /** * Discover all version tags from a local repository and return them * along with their resolved commit hashes. * * This is used for tag auto-discovery when adding a repository or * refreshing available versions (TRUEREF-0019). * * @returns Array of { tag, commitHash } objects, newest first * @throws Error when repository is not local or git operations fail */ discoverTags(repositoryId: string): Array<{ tag: string; commitHash: string }> { const repo = this.db .prepare(`SELECT id, source, source_url FROM repositories WHERE id = ?`) .get(repositoryId) as { id: string; source: string; source_url: string } | undefined; if (!repo) { throw new NotFoundError(`Repository ${repositoryId} not found`); } if (repo.source !== 'local') { throw new Error('Tag discovery is only supported for local repositories'); } const tags = discoverVersionTags({ repoPath: repo.source_url }); return tags.map((tag: string) => { const commitHash = resolveTagToCommit({ repoPath: repo.source_url, tag }); return { tag, commitHash }; }); } }