Files
trueref-legacy/src/lib/server/services/version.service.ts
2026-03-27 03:01:37 +01:00

189 lines
6.4 KiB
TypeScript

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