refactor: introduce domain model classes and mapper layer

Replace ad-hoc inline row casting (snake_case → camelCase) spread across
services, routes, and the indexing pipeline with explicit model classes
(Repository, IndexingJob, RepositoryVersion, Snippet, SearchResult) and
dedicated mapper classes that own the DB → domain conversion.

- Add src/lib/server/models/ with typed model classes for all domain entities
- Add src/lib/server/mappers/ with mapper classes per entity
- Remove duplicated RawRow interfaces and inline map functions from
  job-queue, repository.service, indexing.pipeline, and all API routes
- Add dtoJsonResponse helper to standardise JSON responses via SvelteKit json()
- Add api-contract.integration.test.ts as a regression baseline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-03-25 14:29:49 +01:00
parent 7994254e23
commit 215cadf070
39 changed files with 1339 additions and 562 deletions

View File

@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { RepositoryService } from '$lib/server/services/repository.service';
import { VersionService } from '$lib/server/services/version.service';
let db: Database.Database;
let queue: null = null;
vi.mock('$lib/server/db/client', () => ({
getClient: () => db
}));
vi.mock('$lib/server/db/client.js', () => ({
getClient: () => db
}));
vi.mock('$lib/server/pipeline/startup', () => ({
getQueue: () => queue
}));
vi.mock('$lib/server/pipeline/startup.js', () => ({
getQueue: () => queue
}));
import { POST as postLibraries } from './libs/+server.js';
import { GET as getLibrary } from './libs/[id]/+server.js';
import { GET as getJobs } from './jobs/+server.js';
import { GET as getJob } from './jobs/[id]/+server.js';
import { GET as getVersions, POST as postVersions } from './libs/[id]/versions/+server.js';
function createTestDb(): Database.Database {
const client = new Database(':memory:');
client.pragma('foreign_keys = ON');
const migrationsFolder = join(import.meta.dirname, '../../../lib/server/db/migrations');
const migrationSql = readFileSync(join(migrationsFolder, '0000_large_master_chief.sql'), 'utf-8');
const statements = migrationSql
.split('--> statement-breakpoint')
.map((statement) => statement.trim())
.filter(Boolean);
for (const statement of statements) {
client.exec(statement);
}
return client;
}
describe('API contract integration', () => {
beforeEach(() => {
db = createTestDb();
queue = null;
});
it('POST /api/v1/libs returns repository and job DTOs in camelCase', async () => {
const response = await postLibraries({
request: new Request('http://test/api/v1/libs', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
source: 'github',
sourceUrl: 'https://github.com/facebook/react'
})
}),
url: new URL('http://test/api/v1/libs')
} as never);
expect(response.status).toBe(201);
const body = await response.json();
expect(body.library.sourceUrl).toBe('https://github.com/facebook/react');
expect(body.library.totalSnippets).toBe(0);
expect(body.library.lastIndexedAt).toBeNull();
expect(body.library).not.toHaveProperty('source_url');
expect(body.library).not.toHaveProperty('total_snippets');
expect(body.job.repositoryId).toBe('/facebook/react');
expect(body.job.processedFiles).toBe(0);
expect(body.job).not.toHaveProperty('repository_id');
expect(body.job).not.toHaveProperty('processed_files');
});
it('GET /api/v1/libs/:id returns repository DTO plus version tags', async () => {
const repoService = new RepositoryService(db);
const versionService = new VersionService(db);
repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0');
const response = await getLibrary({
params: { id: encodeURIComponent('/facebook/react') }
} as never);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.sourceUrl).toBe('https://github.com/facebook/react');
expect(body.totalSnippets).toBe(0);
expect(body.versions).toEqual(['v18.3.0']);
expect(body).not.toHaveProperty('source_url');
expect(body).not.toHaveProperty('total_snippets');
});
it('GET /api/v1/jobs and /api/v1/jobs/:id return job DTOs in camelCase', async () => {
const repoService = new RepositoryService(db);
repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
const createdJob = repoService.createIndexingJob('/facebook/react');
const listResponse = await getJobs({
url: new URL('http://test/api/v1/jobs?repositoryId=%2Ffacebook%2Freact')
} as never);
const listBody = await listResponse.json();
expect(listBody.jobs).toHaveLength(1);
expect(listBody.jobs[0].repositoryId).toBe('/facebook/react');
expect(listBody.jobs[0].totalFiles).toBe(0);
expect(listBody.jobs[0]).not.toHaveProperty('repository_id');
const itemResponse = await getJob({
params: { id: createdJob.id }
} as never);
const itemBody = await itemResponse.json();
expect(itemBody.job.repositoryId).toBe('/facebook/react');
expect(itemBody.job.processedFiles).toBe(0);
expect(itemBody.job).not.toHaveProperty('processed_files');
});
it('GET and POST /api/v1/libs/:id/versions return version and job DTOs in camelCase', async () => {
const repoService = new RepositoryService(db);
repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
const postResponse = await postVersions({
params: { id: encodeURIComponent('/facebook/react') },
request: new Request('http://test/api/v1/libs/%2Ffacebook%2Freact/versions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ tag: 'v18.3.0', title: 'React v18.3.0', autoIndex: true })
})
} as never);
const postBody = await postResponse.json();
expect(postResponse.status).toBe(201);
expect(postBody.version.repositoryId).toBe('/facebook/react');
expect(postBody.version.totalSnippets).toBe(0);
expect(postBody.version).not.toHaveProperty('repository_id');
expect(postBody.job.repositoryId).toBe('/facebook/react');
expect(postBody.job).not.toHaveProperty('repository_id');
const getResponse = await getVersions({
params: { id: encodeURIComponent('/facebook/react') }
} as never);
const getBody = await getResponse.json();
expect(getBody.versions).toHaveLength(1);
expect(getBody.versions[0].repositoryId).toBe('/facebook/react');
expect(getBody.versions[0].totalSnippets).toBe(0);
expect(getBody.versions[0]).not.toHaveProperty('repository_id');
expect(getBody.versions[0]).not.toHaveProperty('total_snippets');
});
});

View File

@@ -13,6 +13,7 @@
import type { RequestHandler } from './$types';
import { getClient } from '$lib/server/db/client';
import { dtoJsonResponse } from '$lib/server/api/dto-response';
import { SearchService } from '$lib/server/search/search.service';
import { HybridSearchService } from '$lib/server/search/hybrid.search.service';
import { parseLibraryId } from '$lib/server/api/library-id';
@@ -186,9 +187,9 @@ export const GET: RequestHandler = async ({ url }) => {
// Default: JSON
const body = formatContextJson(selectedResults, rules);
return new Response(JSON.stringify(body), {
return dtoJsonResponse(body, {
status: 200,
headers: { 'Content-Type': 'application/json', ...CORS_HEADERS }
headers: CORS_HEADERS
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Internal server error';

View File

@@ -10,6 +10,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getClient } from '$lib/server/db/client.js';
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
import { JobQueue } from '$lib/server/pipeline/job-queue.js';
import { handleServiceError } from '$lib/server/utils/validation.js';
import type { IndexingJob } from '$lib/types';
@@ -28,7 +29,7 @@ export const GET: RequestHandler = ({ url }) => {
const jobs = queue.listJobs({ repositoryId, status, limit });
const total = queue.countJobs({ repositoryId, status });
return json({ jobs, total });
return json({ jobs: jobs.map((job) => IndexingJobMapper.toDto(job)), total });
} catch (err) {
return handleServiceError(err);
}

View File

@@ -5,6 +5,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getClient } from '$lib/server/db/client.js';
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
import { JobQueue } from '$lib/server/pipeline/job-queue.js';
import { handleServiceError, NotFoundError } from '$lib/server/utils/validation.js';
@@ -16,7 +17,7 @@ export const GET: RequestHandler = ({ params }) => {
const job = queue.getJob(params.id);
if (!job) throw new NotFoundError(`Job ${params.id} not found`);
return json({ job });
return json({ job: IndexingJobMapper.toDto(job) });
} catch (err) {
return handleServiceError(err);
}

View File

@@ -4,60 +4,13 @@
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import type { Repository } from '$lib/types';
import { getClient } from '$lib/server/db/client';
import { RepositoryMapper } from '$lib/server/mappers/repository.mapper.js';
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
import { RepositoryService } from '$lib/server/services/repository.service';
import { getQueue } from '$lib/server/pipeline/startup';
import { handleServiceError } from '$lib/server/utils/validation';
// ---------------------------------------------------------------------------
// Row mapper — better-sqlite3 returns snake_case column names from SELECT *;
// this converts them to the camelCase shape expected by the Repository type
// and by client components (e.g. RepositoryCard reads repo.trustScore).
// ---------------------------------------------------------------------------
interface RawRepoRow {
id: string;
title: string;
description: string | null;
source: string;
source_url: string;
branch: string | null;
state: string;
total_snippets: number;
total_tokens: number;
trust_score: number;
benchmark_score: number;
stars: number | null;
github_token: string | null;
last_indexed_at: number | null;
created_at: number;
updated_at: number;
}
function mapRepo(raw: Repository): Repository {
const r = raw as unknown as RawRepoRow;
return {
id: r.id,
title: r.title,
description: r.description ?? null,
source: r.source as Repository['source'],
sourceUrl: r.source_url,
branch: r.branch ?? null,
state: r.state as Repository['state'],
totalSnippets: r.total_snippets ?? 0,
totalTokens: r.total_tokens ?? 0,
trustScore: r.trust_score ?? 0,
benchmarkScore: r.benchmark_score ?? 0,
stars: r.stars ?? null,
githubToken: r.github_token ?? null,
lastIndexedAt:
r.last_indexed_at != null ? new Date(r.last_indexed_at * 1000) : null,
createdAt: new Date(r.created_at * 1000),
updatedAt: new Date(r.updated_at * 1000)
};
}
function getService() {
return new RepositoryService(getClient());
}
@@ -77,14 +30,10 @@ export const GET: RequestHandler = ({ url }) => {
const libraries = service.list({ state: state ?? undefined, limit, offset });
const total = service.count(state ?? undefined);
// Map raw snake_case rows to camelCase, augment with versions, strip sensitive fields.
const enriched = libraries.map((rawRepo) => {
const { githubToken: _token, ...repo } = mapRepo(rawRepo);
return {
...repo,
versions: service.getVersions(repo.id)
};
});
const enriched = libraries.map((repo) => ({
...RepositoryMapper.toDto(repo),
versions: service.getVersions(repo.id)
}));
return json({ libraries: enriched, total, limit, offset });
} catch (err) {
@@ -106,18 +55,17 @@ export const POST: RequestHandler = async ({ request }) => {
githubToken: body.githubToken
});
let jobResponse: { id: string; status: string } | null = null;
let jobResponse: ReturnType<typeof IndexingJobMapper.toDto> | null = null;
if (body.autoIndex !== false) {
const queue = getQueue();
const job = queue
? queue.enqueue(repo.id)
: service.createIndexingJob(repo.id);
jobResponse = { id: job.id, status: job.status };
jobResponse = IndexingJobMapper.toDto(job);
}
const { githubToken: _token, ...safeRepo } = repo;
return json(
{ library: safeRepo, ...(jobResponse ? { job: jobResponse } : {}) },
{ library: RepositoryMapper.toDto(repo), ...(jobResponse ? { job: jobResponse } : {}) },
{ status: 201 }
);
} catch (err) {

View File

@@ -6,6 +6,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getClient } from '$lib/server/db/client';
import { RepositoryMapper } from '$lib/server/mappers/repository.mapper.js';
import { RepositoryService } from '$lib/server/services/repository.service';
import { handleServiceError } from '$lib/server/utils/validation';
@@ -22,8 +23,7 @@ export const GET: RequestHandler = ({ params }) => {
return json({ error: 'Repository not found', code: 'NOT_FOUND' }, { status: 404 });
}
const versions = service.getVersions(id);
const { githubToken: _token, ...safeRepo } = repo;
return json({ ...safeRepo, versions });
return json({ ...RepositoryMapper.toDto(repo), versions });
} catch (err) {
return handleServiceError(err);
}
@@ -40,8 +40,7 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
branch: body.branch,
githubToken: body.githubToken
});
const { githubToken: _token, ...safeUpdated } = updated;
return json(safeUpdated);
return json(RepositoryMapper.toDto(updated));
} catch (err) {
return handleServiceError(err);
}

View File

@@ -4,6 +4,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getClient } from '$lib/server/db/client';
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
import { RepositoryService } from '$lib/server/services/repository.service';
import { getQueue } from '$lib/server/pipeline/startup';
import { handleServiceError, NotFoundError } from '$lib/server/utils/validation';
@@ -31,7 +32,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
? queue.enqueue(id, versionId)
: service.createIndexingJob(id, versionId);
return json({ job }, { status: 202 });
return json({ job: IndexingJobMapper.toDto(job) }, { status: 202 });
} catch (err) {
return handleServiceError(err);
}

View File

@@ -6,6 +6,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getClient } from '$lib/server/db/client';
import { RepositoryVersionMapper } from '$lib/server/mappers/repository-version.mapper.js';
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
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';
@@ -33,7 +35,7 @@ export const GET: RequestHandler = ({ params }) => {
}
const versions = versionService.list(repositoryId);
return json({ versions });
return json({ versions: versions.map((version) => RepositoryVersionMapper.toDto(version)) });
} catch (err) {
return handleServiceError(err);
}
@@ -74,13 +76,16 @@ export const POST: RequestHandler = async ({ params, request }) => {
const version = versionService.add(repositoryId, tag.trim(), title);
let job: { id: string; status: string } | undefined;
let job: ReturnType<typeof IndexingJobMapper.toDto> | undefined;
if (autoIndex) {
const indexingJob = repoService.createIndexingJob(repositoryId, version.id);
job = { id: indexingJob.id, status: indexingJob.status };
job = IndexingJobMapper.toDto(indexingJob);
}
return json({ version, ...(job ? { job } : {}) }, { status: 201 });
return json(
{ version: RepositoryVersionMapper.toDto(version), ...(job ? { job } : {}) },
{ status: 201 }
);
} catch (err) {
return handleServiceError(err);
}

View File

@@ -5,6 +5,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getClient } from '$lib/server/db/client';
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
import { RepositoryService } from '$lib/server/services/repository.service';
import { VersionService } from '$lib/server/services/version.service';
import { getQueue } from '$lib/server/pipeline/startup';
@@ -42,7 +43,7 @@ export const POST: RequestHandler = ({ params }) => {
const job = queue
? queue.enqueue(repositoryId, version.id)
: repoService.createIndexingJob(repositoryId, version.id);
return json({ job }, { status: 202 });
return json({ job: IndexingJobMapper.toDto(job) }, { status: 202 });
} catch (err) {
return handleServiceError(err);
}

View File

@@ -14,6 +14,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getClient } from '$lib/server/db/client';
import { dtoJsonResponse } from '$lib/server/api/dto-response';
import { SearchService } from '$lib/server/search/search.service';
import { formatLibrarySearchJson } from '$lib/server/api/formatters';
import { CORS_HEADERS } from '$lib/server/api/formatters';
@@ -44,7 +45,7 @@ export const GET: RequestHandler = ({ url }) => {
const results = service.searchRepositories({ libraryName, query, limit });
const body = formatLibrarySearchJson(results);
return json(body, {
return dtoJsonResponse(body, {
headers: CORS_HEADERS
});
} catch (err) {

View File

@@ -14,6 +14,16 @@ import {
} from '$lib/server/embeddings/factory';
import { handleServiceError, InvalidInputError } from '$lib/server/utils/validation';
export const GET: RequestHandler = async () => {
try {
const provider = createProviderFromConfig({ provider: 'local' });
const available = await provider.isAvailable();
return json({ available });
} catch (err) {
return handleServiceError(err);
}
};
// ---------------------------------------------------------------------------
// Validate — reuse the same shape accepted by PUT /settings/embedding
// ---------------------------------------------------------------------------