189 lines
6.4 KiB
TypeScript
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 };
|
|
});
|
|
}
|
|
}
|