feat(TRUEREF-0014): implement repository version management
- VersionService with list, add, remove, getByTag, registerFromConfig - GitHub tag discovery helper for validating tags before indexing - Version ID format: /owner/repo/tag (e.g. /facebook/react/v18.3.0) - GET/POST /api/v1/libs/:id/versions - DELETE /api/v1/libs/:id/versions/:tag - POST /api/v1/libs/:id/versions/:tag/index Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
110
src/lib/server/crawler/github-tags.test.ts
Normal file
110
src/lib/server/crawler/github-tags.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for github-tags helper.
|
||||||
|
*
|
||||||
|
* External HTTP calls are mocked via vitest's global fetch stub so the tests
|
||||||
|
* run fully offline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { listGitHubTags, findGitHubTag, GitHubApiError } from './github-tags';
|
||||||
|
|
||||||
|
const MOCK_TAGS = [
|
||||||
|
{ name: 'v18.3.0', commit: { sha: 'abc123' } },
|
||||||
|
{ name: 'v17.0.2', commit: { sha: 'def456' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
function mockFetch(status: number, body: unknown) {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', mockFetch(200, MOCK_TAGS));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// listGitHubTags()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('listGitHubTags()', () => {
|
||||||
|
it('returns parsed tag list on success', async () => {
|
||||||
|
const tags = await listGitHubTags('facebook', 'react');
|
||||||
|
expect(tags).toHaveLength(2);
|
||||||
|
expect(tags[0].name).toBe('v18.3.0');
|
||||||
|
expect(tags[0].commit.sha).toBe('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls the correct GitHub API URL', async () => {
|
||||||
|
await listGitHubTags('facebook', 'react');
|
||||||
|
const [url] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe('https://api.github.com/repos/facebook/react/tags?per_page=100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes Accept and User-Agent headers', async () => {
|
||||||
|
await listGitHubTags('facebook', 'react');
|
||||||
|
const [, init] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect(headers['Accept']).toBe('application/vnd.github.v3+json');
|
||||||
|
expect(headers['User-Agent']).toBe('TrueRef/1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes Authorization header when token is provided', async () => {
|
||||||
|
await listGitHubTags('facebook', 'react', 'mytoken');
|
||||||
|
const [, init] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect(headers['Authorization']).toBe('Bearer mytoken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include Authorization header when token is absent', async () => {
|
||||||
|
await listGitHubTags('facebook', 'react');
|
||||||
|
const [, init] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect(headers['Authorization']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws GitHubApiError on non-2xx response', async () => {
|
||||||
|
vi.stubGlobal('fetch', mockFetch(404, { message: 'Not Found' }));
|
||||||
|
await expect(listGitHubTags('facebook', 'react')).rejects.toThrow(GitHubApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GitHubApiError carries the HTTP status code', async () => {
|
||||||
|
vi.stubGlobal('fetch', mockFetch(403, {}));
|
||||||
|
try {
|
||||||
|
await listGitHubTags('facebook', 'react');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(GitHubApiError);
|
||||||
|
expect((err as GitHubApiError).status).toBe(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// findGitHubTag()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('findGitHubTag()', () => {
|
||||||
|
it('returns the tag record when it exists', async () => {
|
||||||
|
const tag = await findGitHubTag('facebook', 'react', 'v18.3.0');
|
||||||
|
expect(tag).not.toBeNull();
|
||||||
|
expect(tag?.name).toBe('v18.3.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the tag does not exist', async () => {
|
||||||
|
const tag = await findGitHubTag('facebook', 'react', 'v99.0.0');
|
||||||
|
expect(tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the token to listGitHubTags', async () => {
|
||||||
|
await findGitHubTag('facebook', 'react', 'v18.3.0', 'mytoken');
|
||||||
|
const [, init] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect(headers['Authorization']).toBe('Bearer mytoken');
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/lib/server/crawler/github-tags.ts
Normal file
59
src/lib/server/crawler/github-tags.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* GitHub tag discovery helper for TRUEREF-0014.
|
||||||
|
*
|
||||||
|
* Fetches the list of git tags for a GitHub repository, used to validate
|
||||||
|
* that a requested tag exists before queuing an indexing job.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GitHubTag {
|
||||||
|
name: string;
|
||||||
|
commit: { sha: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GitHubApiError extends Error {
|
||||||
|
constructor(public readonly status: number) {
|
||||||
|
super(`GitHub API error: HTTP ${status}`);
|
||||||
|
this.name = 'GitHubApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List up to 100 tags for a GitHub repository.
|
||||||
|
*
|
||||||
|
* @param owner GitHub owner/organisation name
|
||||||
|
* @param repo GitHub repository name
|
||||||
|
* @param token Optional personal access token for private repos / rate-limit relief
|
||||||
|
*/
|
||||||
|
export async function listGitHubTags(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
token?: string
|
||||||
|
): Promise<GitHubTag[]> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': 'TrueRef/1.0'
|
||||||
|
};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.github.com/repos/${owner}/${repo}/tags?per_page=100`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) throw new GitHubApiError(response.status);
|
||||||
|
return response.json() as Promise<GitHubTag[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a specific tag name exists in the remote repository.
|
||||||
|
* Returns the matching tag record, or `null` if absent.
|
||||||
|
*/
|
||||||
|
export async function findGitHubTag(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
tag: string,
|
||||||
|
token?: string
|
||||||
|
): Promise<GitHubTag | null> {
|
||||||
|
const tags = await listGitHubTags(owner, repo, token);
|
||||||
|
return tags.find((t) => t.name === tag) ?? null;
|
||||||
|
}
|
||||||
275
src/lib/server/services/version.service.test.ts
Normal file
275
src/lib/server/services/version.service.test.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for VersionService.
|
||||||
|
*
|
||||||
|
* Uses an in-memory SQLite database seeded from the canonical migration file.
|
||||||
|
* Returned rows have snake_case column names matching the raw better-sqlite3
|
||||||
|
* output — the same pattern used by repository.service.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { VersionService } from './version.service';
|
||||||
|
import { RepositoryService } from './repository.service';
|
||||||
|
import { AlreadyExistsError, NotFoundError } from '$lib/server/utils/validation';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test DB factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createTestDb(): Database.Database {
|
||||||
|
const client = new Database(':memory:');
|
||||||
|
client.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
||||||
|
const migrationSql = readFileSync(
|
||||||
|
join(migrationsFolder, '0000_large_master_chief.sql'),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const statements = migrationSql
|
||||||
|
.split('--> statement-breakpoint')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const stmt of statements) {
|
||||||
|
client.exec(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw row shape returned by better-sqlite3 SELECT * FROM repository_versions.
|
||||||
|
interface RawVersion {
|
||||||
|
id: string;
|
||||||
|
repository_id: string;
|
||||||
|
tag: string;
|
||||||
|
title: string | null;
|
||||||
|
state: string;
|
||||||
|
total_snippets: number;
|
||||||
|
indexed_at: number | null;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const client = createTestDb();
|
||||||
|
const repoService = new RepositoryService(client);
|
||||||
|
const versionService = new VersionService(client);
|
||||||
|
|
||||||
|
// Add a parent repository used across most tests.
|
||||||
|
repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
|
||||||
|
|
||||||
|
return { client, repoService, versionService };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// list()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('VersionService.list()', () => {
|
||||||
|
it('returns an empty array when no versions exist', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
expect(versionService.list('/facebook/react')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all versions for a repository', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0');
|
||||||
|
versionService.add('/facebook/react', 'v17.0.2', 'React v17.0.2');
|
||||||
|
const versions = versionService.list('/facebook/react');
|
||||||
|
expect(versions).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns newest versions first', () => {
|
||||||
|
const { client, versionService } = setup();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
// Insert with explicit timestamps to guarantee ordering.
|
||||||
|
client
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO repository_versions (id, repository_id, tag, state, total_snippets, created_at)
|
||||||
|
VALUES (?, '/facebook/react', ?, 'pending', 0, ?)`
|
||||||
|
)
|
||||||
|
.run('/facebook/react/v17.0.2', 'v17.0.2', now - 10);
|
||||||
|
client
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO repository_versions (id, repository_id, tag, state, total_snippets, created_at)
|
||||||
|
VALUES (?, '/facebook/react', ?, 'pending', 0, ?)`
|
||||||
|
)
|
||||||
|
.run('/facebook/react/v18.3.0', 'v18.3.0', now);
|
||||||
|
const versions = versionService.list('/facebook/react') as unknown as RawVersion[];
|
||||||
|
// Newest created_at first.
|
||||||
|
expect(versions[0].tag).toBe('v18.3.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for an unknown repository (no FK check on list)', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
expect(versionService.list('/unknown/repo')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// add()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('VersionService.add()', () => {
|
||||||
|
it('creates a version with the correct ID format', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
const version = versionService.add(
|
||||||
|
'/facebook/react',
|
||||||
|
'v18.3.0',
|
||||||
|
'React v18.3.0'
|
||||||
|
) as unknown as RawVersion;
|
||||||
|
expect(version.id).toBe('/facebook/react/v18.3.0');
|
||||||
|
expect(version.repository_id).toBe('/facebook/react');
|
||||||
|
expect(version.tag).toBe('v18.3.0');
|
||||||
|
expect(version.title).toBe('React v18.3.0');
|
||||||
|
expect(version.state).toBe('pending');
|
||||||
|
expect(version.total_snippets).toBe(0);
|
||||||
|
expect(version.indexed_at).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a version without a title', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
const version = versionService.add(
|
||||||
|
'/facebook/react',
|
||||||
|
'v18.3.0'
|
||||||
|
) as unknown as RawVersion;
|
||||||
|
expect(version.title).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when the parent repository does not exist', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
expect(() => versionService.add('/unknown/repo', 'v1.0.0')).toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws AlreadyExistsError when adding a duplicate tag', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
versionService.add('/facebook/react', 'v18.3.0');
|
||||||
|
expect(() => versionService.add('/facebook/react', 'v18.3.0')).toThrow(AlreadyExistsError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the same tag for different repositories', () => {
|
||||||
|
const { repoService, versionService } = setup();
|
||||||
|
// Use a repo name without dots so resolveGitHubId produces a predictable ID.
|
||||||
|
repoService.add({ source: 'github', sourceUrl: 'https://github.com/vercel/nextjs' });
|
||||||
|
versionService.add('/facebook/react', 'v18.3.0');
|
||||||
|
const v = versionService.add('/vercel/nextjs', 'v18.3.0') as unknown as RawVersion;
|
||||||
|
expect(v.id).toBe('/vercel/nextjs/v18.3.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// remove()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('VersionService.remove()', () => {
|
||||||
|
it('removes an existing version', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
versionService.add('/facebook/react', 'v18.3.0');
|
||||||
|
versionService.remove('/facebook/react', 'v18.3.0');
|
||||||
|
expect(versionService.getByTag('/facebook/react', 'v18.3.0')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when the version does not exist', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
expect(() => versionService.remove('/facebook/react', 'v99.0.0')).toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cascades to documents and snippets on delete', () => {
|
||||||
|
const { client, versionService } = setup();
|
||||||
|
versionService.add('/facebook/react', 'v18.3.0');
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const docId = crypto.randomUUID();
|
||||||
|
client
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO documents (id, repository_id, version_id, file_path, checksum, indexed_at)
|
||||||
|
VALUES (?, '/facebook/react', '/facebook/react/v18.3.0', 'README.md', 'abc', ?)`
|
||||||
|
)
|
||||||
|
.run(docId, now);
|
||||||
|
|
||||||
|
versionService.remove('/facebook/react', 'v18.3.0');
|
||||||
|
|
||||||
|
const doc = client
|
||||||
|
.prepare(`SELECT id FROM documents WHERE id = ?`)
|
||||||
|
.get(docId);
|
||||||
|
expect(doc).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getByTag()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('VersionService.getByTag()', () => {
|
||||||
|
it('returns null when the version does not exist', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
expect(versionService.getByTag('/facebook/react', 'v99.0.0')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the version record when it exists', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0');
|
||||||
|
const version = versionService.getByTag(
|
||||||
|
'/facebook/react',
|
||||||
|
'v18.3.0'
|
||||||
|
) as unknown as RawVersion;
|
||||||
|
expect(version).not.toBeNull();
|
||||||
|
expect(version.tag).toBe('v18.3.0');
|
||||||
|
expect(version.repository_id).toBe('/facebook/react');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// registerFromConfig()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('VersionService.registerFromConfig()', () => {
|
||||||
|
it('registers multiple versions from config', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
const result = versionService.registerFromConfig('/facebook/react', [
|
||||||
|
{ tag: 'v18.3.0', title: 'React v18.3.0' },
|
||||||
|
{ tag: 'v17.0.2', title: 'React v17.0.2' }
|
||||||
|
]);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(versionService.list('/facebook/react')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips already-registered tags idempotently', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0');
|
||||||
|
const result = versionService.registerFromConfig('/facebook/react', [
|
||||||
|
{ tag: 'v18.3.0', title: 'React v18.3.0 (duplicate)' },
|
||||||
|
{ tag: 'v17.0.2', title: 'React v17.0.2' }
|
||||||
|
]);
|
||||||
|
// Both entries are returned but only one new row is created.
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(versionService.list('/facebook/react')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when given an empty previousVersions list', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
const result = versionService.registerFromConfig('/facebook/react', []);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when the parent repository does not exist', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
expect(() =>
|
||||||
|
versionService.registerFromConfig('/unknown/repo', [{ tag: 'v1.0.0', title: 'v1' }])
|
||||||
|
).toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets all registered versions to state pending', () => {
|
||||||
|
const { versionService } = setup();
|
||||||
|
versionService.registerFromConfig('/facebook/react', [
|
||||||
|
{ tag: 'v18.3.0', title: 'React v18.3.0' }
|
||||||
|
]);
|
||||||
|
const version = versionService.getByTag(
|
||||||
|
'/facebook/react',
|
||||||
|
'v18.3.0'
|
||||||
|
) as unknown as RawVersion;
|
||||||
|
expect(version.state).toBe('pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
134
src/lib/server/services/version.service.ts
Normal file
134
src/lib/server/services/version.service.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 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 type { RepositoryVersion } from '$lib/types';
|
||||||
|
import { AlreadyExistsError, NotFoundError } from '$lib/server/utils/validation';
|
||||||
|
|
||||||
|
export class VersionService {
|
||||||
|
constructor(private readonly db: Database.Database) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all versions for a repository, newest first.
|
||||||
|
*/
|
||||||
|
list(repositoryId: string): RepositoryVersion[] {
|
||||||
|
return this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM repository_versions
|
||||||
|
WHERE repository_id = ?
|
||||||
|
ORDER BY created_at DESC`
|
||||||
|
)
|
||||||
|
.all(repositoryId) as RepositoryVersion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new version record for a repository.
|
||||||
|
* The version ID follows the convention: {repositoryId}/{tag}
|
||||||
|
*
|
||||||
|
* @throws NotFoundError when the parent repository does not exist
|
||||||
|
* @throws AlreadyExistsError when the tag is already registered
|
||||||
|
*/
|
||||||
|
add(repositoryId: string, tag: string, title?: 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 id = `${repositoryId}/${tag}`;
|
||||||
|
|
||||||
|
// Check for collision.
|
||||||
|
const existing = this.getByTag(repositoryId, tag);
|
||||||
|
if (existing) {
|
||||||
|
throw new AlreadyExistsError(`Version ${tag} already exists for repository ${repositoryId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO repository_versions
|
||||||
|
(id, repository_id, tag, title, state, total_snippets, indexed_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'pending', 0, NULL, ?)`
|
||||||
|
)
|
||||||
|
.run(id, repositoryId, tag, title ?? null, now);
|
||||||
|
|
||||||
|
return this.db
|
||||||
|
.prepare(`SELECT * FROM repository_versions WHERE id = ?`)
|
||||||
|
.get(id) as RepositoryVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
return (
|
||||||
|
(this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM repository_versions WHERE repository_id = ? AND tag = ?`
|
||||||
|
)
|
||||||
|
.get(repositoryId, tag) as RepositoryVersion | undefined) ?? 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'.
|
||||||
|
*
|
||||||
|
* @throws NotFoundError when the parent repository does not exist
|
||||||
|
*/
|
||||||
|
registerFromConfig(
|
||||||
|
repositoryId: string,
|
||||||
|
previousVersions: { tag: string; title: 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 } 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);
|
||||||
|
registered.push(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
return registered;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/routes/api/v1/libs/[id]/versions/+server.ts
Normal file
102
src/routes/api/v1/libs/[id]/versions/+server.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/v1/libs/:id/versions — list all indexed versions for a repository
|
||||||
|
* POST /api/v1/libs/:id/versions — add a new version (tag or branch)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getClient } from '$lib/server/db/client';
|
||||||
|
import { RepositoryService } from '$lib/server/services/repository.service';
|
||||||
|
import { VersionService } from '$lib/server/services/version.service';
|
||||||
|
import { handleServiceError, NotFoundError, InvalidInputError } from '$lib/server/utils/validation';
|
||||||
|
|
||||||
|
function getServices() {
|
||||||
|
const db = getClient();
|
||||||
|
return {
|
||||||
|
repoService: new RepositoryService(db),
|
||||||
|
versionService: new VersionService(db)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/v1/libs/:id/versions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const GET: RequestHandler = ({ params }) => {
|
||||||
|
try {
|
||||||
|
const { repoService, versionService } = getServices();
|
||||||
|
const repositoryId = decodeURIComponent(params.id);
|
||||||
|
|
||||||
|
const repo = repoService.get(repositoryId);
|
||||||
|
if (!repo) {
|
||||||
|
throw new NotFoundError(`Repository ${repositoryId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = versionService.list(repositoryId);
|
||||||
|
return json({ versions });
|
||||||
|
} catch (err) {
|
||||||
|
return handleServiceError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/v1/libs/:id/versions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request }) => {
|
||||||
|
try {
|
||||||
|
const { repoService, versionService } = getServices();
|
||||||
|
const repositoryId = decodeURIComponent(params.id);
|
||||||
|
|
||||||
|
const repo = repoService.get(repositoryId);
|
||||||
|
if (!repo) {
|
||||||
|
throw new NotFoundError(`Repository ${repositoryId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { tag?: unknown; title?: unknown; autoIndex?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
throw new InvalidInputError('Request body must be valid JSON', [
|
||||||
|
{ field: 'body', message: 'Invalid JSON' }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = body.tag;
|
||||||
|
if (!tag || typeof tag !== 'string' || !tag.trim()) {
|
||||||
|
throw new InvalidInputError('tag is required', [
|
||||||
|
{ field: 'tag', message: 'tag must be a non-empty string' }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = typeof body.title === 'string' ? body.title : undefined;
|
||||||
|
const autoIndex = body.autoIndex === true;
|
||||||
|
|
||||||
|
const version = versionService.add(repositoryId, tag.trim(), title);
|
||||||
|
|
||||||
|
let job: { id: string; status: string } | undefined;
|
||||||
|
if (autoIndex) {
|
||||||
|
const indexingJob = repoService.createIndexingJob(repositoryId, version.id);
|
||||||
|
job = { id: indexingJob.id, status: indexingJob.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ version, ...(job ? { job } : {}) }, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
return handleServiceError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OPTIONS preflight
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const OPTIONS: RequestHandler = () => {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
54
src/routes/api/v1/libs/[id]/versions/[tag]/+server.ts
Normal file
54
src/routes/api/v1/libs/[id]/versions/[tag]/+server.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* DELETE /api/v1/libs/:id/versions/:tag — remove a version and all its snippets
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getClient } from '$lib/server/db/client';
|
||||||
|
import { RepositoryService } from '$lib/server/services/repository.service';
|
||||||
|
import { VersionService } from '$lib/server/services/version.service';
|
||||||
|
import { handleServiceError, NotFoundError } from '$lib/server/utils/validation';
|
||||||
|
|
||||||
|
function getServices() {
|
||||||
|
const db = getClient();
|
||||||
|
return {
|
||||||
|
repoService: new RepositoryService(db),
|
||||||
|
versionService: new VersionService(db)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /api/v1/libs/:id/versions/:tag
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = ({ params }) => {
|
||||||
|
try {
|
||||||
|
const { repoService, versionService } = getServices();
|
||||||
|
const repositoryId = decodeURIComponent(params.id);
|
||||||
|
const tag = decodeURIComponent(params.tag);
|
||||||
|
|
||||||
|
const repo = repoService.get(repositoryId);
|
||||||
|
if (!repo) {
|
||||||
|
throw new NotFoundError(`Repository ${repositoryId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
versionService.remove(repositoryId, tag);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch (err) {
|
||||||
|
return handleServiceError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OPTIONS preflight
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const OPTIONS: RequestHandler = () => {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
60
src/routes/api/v1/libs/[id]/versions/[tag]/index/+server.ts
Normal file
60
src/routes/api/v1/libs/[id]/versions/[tag]/index/+server.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/v1/libs/:id/versions/:tag/index — queue an indexing job for a specific version
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getClient } from '$lib/server/db/client';
|
||||||
|
import { RepositoryService } from '$lib/server/services/repository.service';
|
||||||
|
import { VersionService } from '$lib/server/services/version.service';
|
||||||
|
import { handleServiceError, NotFoundError } from '$lib/server/utils/validation';
|
||||||
|
|
||||||
|
function getServices() {
|
||||||
|
const db = getClient();
|
||||||
|
return {
|
||||||
|
repoService: new RepositoryService(db),
|
||||||
|
versionService: new VersionService(db)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/v1/libs/:id/versions/:tag/index
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const POST: RequestHandler = ({ params }) => {
|
||||||
|
try {
|
||||||
|
const { repoService, versionService } = getServices();
|
||||||
|
const repositoryId = decodeURIComponent(params.id);
|
||||||
|
const tag = decodeURIComponent(params.tag);
|
||||||
|
|
||||||
|
const repo = repoService.get(repositoryId);
|
||||||
|
if (!repo) {
|
||||||
|
throw new NotFoundError(`Repository ${repositoryId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = versionService.getByTag(repositoryId, tag);
|
||||||
|
if (!version) {
|
||||||
|
throw new NotFoundError(`Version ${tag} not found for repository ${repositoryId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = repoService.createIndexingJob(repositoryId, version.id);
|
||||||
|
return json({ job }, { status: 202 });
|
||||||
|
} catch (err) {
|
||||||
|
return handleServiceError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OPTIONS preflight
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const OPTIONS: RequestHandler = () => {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user