diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index 13cf45f..13892fd 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -165,3 +165,26 @@ Add subsequent research below this section. - Risks / follow-ups: - Base-aware navigation fixes must preserve internal app routing semantics and should not replace intentional external navigation, because SvelteKit `goto(...)` no longer accepts external URLs. - Settings and search page lifecycle changes must avoid reintroducing SSR-triggered fetches or self-triggered URL loops; client-only bootstrap logic should remain mounted once and URL-sync effects must stay idempotent. + +### 2026-03-27 — ROUTING-0001 planning research + +- Task: Plan the repository-detail routing fix for slash-bearing repository IDs causing homepage SSR failures and invalid `/repos/[id]` navigation. +- Files inspected: + - `package.json` + - `src/lib/components/RepositoryCard.svelte` + - `src/routes/+page.svelte` + - `src/routes/+page.server.ts` + - `src/routes/repos/[id]/+page.server.ts` + - `src/routes/repos/[id]/+page.svelte` + - `src/routes/api/v1/api-contract.integration.test.ts` + - `src/lib/types.ts` +- Findings: + - The app is on SvelteKit `^2.50.2` and uses `$app/paths.resolve(...)` for internal navigation, including `resolveRoute('/repos/[id]', { id: repo.id })` in `RepositoryCard.svelte`. + - SvelteKit’s `[id]` route is a single-segment dynamic parameter. Context7 routing docs show slash-containing values belong to rest parameters like `[...param]`, so raw repository IDs containing `/` are invalid inputs for `resolveRoute('/repos/[id]', ...)`. + - The repository model intentionally stores slash-bearing IDs such as `/facebook/react`, and the existing API surface consistently treats those IDs as percent-encoded path segments. The integration contract already passes `params.id = encodeURIComponent('/facebook/react')` for `/api/v1/libs/[id]` handlers, which then call `decodeURIComponent(params.id)`. + - The homepage SSR failure is therefore rooted in UI link generation, not repository listing fetches: rendering `RepositoryCard.svelte` with a raw slash-bearing `repo.id` can throw before page load completes, which explains repeated `500` responses on `/`. + - The repo detail page currently forwards `params.id` directly into `encodeURIComponent(...)` for downstream API requests. Once detail links are generated as encoded single segments, the page loader and client-side refresh/delete/reindex flows need one normalization step so API calls continue targeting the stored repository ID instead of a doubly encoded value. + - No existing browser-facing test covers homepage card navigation or `/repos/[id]` loader behavior; the closest current evidence is the API contract test file, which already exercises encoded repository IDs on HTTP endpoints and provides reusable fixtures for slash-bearing IDs. +- Risks / follow-ups: + - The fix should preserve the existing `/repos/[id]` route shape instead of redesigning it to a rest route unless a broader navigation contract change is explicitly requested. + - Any normalization helper introduced for the repo detail page should be reused consistently across server load and client event handlers to avoid mixed encoded and decoded repository IDs during navigation and fetches. diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..5531a15 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,79 @@ +import { mkdir, readdir, rename } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const routesDir = join(rootDir, 'src', 'routes'); +const renamedRouteTestFiles = []; + +async function collectReservedRouteTestFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const entryPath = join(directory, entry.name); + + if (entry.isDirectory()) { + files.push(...(await collectReservedRouteTestFiles(entryPath))); + continue; + } + + if (!entry.name.startsWith('+')) { + continue; + } + + if (!entry.name.includes('.test.') && !entry.name.includes('.spec.')) { + continue; + } + + files.push(entryPath); + } + + return files; +} + +async function renameReservedRouteTests() { + const reservedRouteTestFiles = await collectReservedRouteTestFiles(routesDir); + + for (const sourcePath of reservedRouteTestFiles) { + const targetPath = join(dirname(sourcePath), basename(sourcePath).slice(1)); + await rename(sourcePath, targetPath); + renamedRouteTestFiles.push({ sourcePath, targetPath }); + } +} + +async function restoreReservedRouteTests() { + for (const { sourcePath, targetPath } of renamedRouteTestFiles.reverse()) { + await mkdir(dirname(sourcePath), { recursive: true }); + await rename(targetPath, sourcePath); + } +} + +function runViteBuild() { + const viteBinPath = join(rootDir, 'node_modules', 'vite', 'bin', 'vite.js'); + + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [viteBinPath, 'build'], { + cwd: rootDir, + stdio: 'inherit' + }); + + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`vite build exited with code ${code ?? 'unknown'}`)); + }); + }); +} + +try { + await renameReservedRouteTests(); + await runViteBuild(); +} finally { + await restoreReservedRouteTests(); +} \ No newline at end of file diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 56fb88d..d66593b 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -9,13 +9,13 @@ import { initializeDatabase } from '$lib/server/db/index.js'; import { getClient } from '$lib/server/db/client.js'; import { initializePipeline } from '$lib/server/pipeline/startup.js'; -import { - EMBEDDING_CONFIG_KEY, - createProviderFromConfig, - defaultEmbeddingConfig -} from '$lib/server/embeddings/factory.js'; +import { createProviderFromProfile } from '$lib/server/embeddings/registry.js'; import { EmbeddingService } from '$lib/server/embeddings/embedding.service.js'; -import type { EmbeddingConfig } from '$lib/server/embeddings/factory.js'; +import { + EmbeddingProfileEntity, + type EmbeddingProfileEntityProps +} from '$lib/server/models/embedding-profile.js'; +import { EmbeddingProfileMapper } from '$lib/server/mappers/embedding-profile.mapper.js'; import type { Handle } from '@sveltejs/kit'; // --------------------------------------------------------------------------- @@ -26,37 +26,20 @@ try { initializeDatabase(); const db = getClient(); - - // Load persisted embedding configuration (if any). - const configRow = db - .prepare<[string], { value: string }>(`SELECT value FROM settings WHERE key = ?`) - .get(EMBEDDING_CONFIG_KEY); + const activeProfileRow = db + .prepare<[], EmbeddingProfileEntityProps>( + 'SELECT * FROM embedding_profiles WHERE is_default = 1 AND enabled = 1 LIMIT 1' + ) + .get(); let embeddingService: EmbeddingService | null = null; - if (configRow) { - try { - const config: EmbeddingConfig = - typeof configRow.value === 'string' - ? JSON.parse(configRow.value) - : (configRow.value as EmbeddingConfig); - - if (config.provider !== 'none') { - const provider = createProviderFromConfig(config); - embeddingService = new EmbeddingService(db, provider); - } - } catch (err) { - console.warn( - `[hooks.server] Could not load embedding config: ${err instanceof Error ? err.message : String(err)}` - ); - } - } else { - // Use the default (noop) config so the pipeline is still wired up. - const config = defaultEmbeddingConfig(); - if (config.provider !== 'none') { - const provider = createProviderFromConfig(config); - embeddingService = new EmbeddingService(db, provider); - } + if (activeProfileRow) { + const activeProfile = EmbeddingProfileMapper.fromEntity( + new EmbeddingProfileEntity(activeProfileRow) + ); + const provider = createProviderFromProfile(activeProfile); + embeddingService = new EmbeddingService(db, provider, activeProfile.id); } initializePipeline(db, embeddingService); diff --git a/src/lib/components/RepositoryCard.svelte b/src/lib/components/RepositoryCard.svelte index 18d6d07..8cbdb84 100644 --- a/src/lib/components/RepositoryCard.svelte +++ b/src/lib/components/RepositoryCard.svelte @@ -1,13 +1,25 @@
Indexing failed. Check jobs for details.
{/if} @@ -77,7 +112,7 @@ {repo.state === 'indexing' ? 'Indexing...' : 'Re-index'} Details diff --git a/src/lib/components/RepositoryCard.svelte.test.ts b/src/lib/components/RepositoryCard.svelte.test.ts new file mode 100644 index 0000000..34a5511 --- /dev/null +++ b/src/lib/components/RepositoryCard.svelte.test.ts @@ -0,0 +1,32 @@ +import { page } from 'vitest/browser'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import RepositoryCard from './RepositoryCard.svelte'; + +describe('RepositoryCard.svelte', () => { + it('encodes slash-bearing repository ids in the details href', async () => { + render(RepositoryCard, { + repo: { + id: '/facebook/react', + title: 'React', + description: 'A JavaScript library for building user interfaces', + state: 'indexed', + totalSnippets: 1234, + embeddingCount: 1200, + indexedVersions: ['main', 'v18.3.0'], + trustScore: 9.7, + stars: 230000, + lastIndexedAt: null + } as never, + onReindex: vi.fn(), + onDelete: vi.fn() + }); + + await expect + .element(page.getByRole('link', { name: 'Details' })) + .toHaveAttribute('href', '/repos/%2Ffacebook%2Freact'); + + await expect.element(page.getByText('1,200 embeddings')).toBeInTheDocument(); + await expect.element(page.getByText('Indexed: main, v18.3.0')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/lib/dtos/embedding-settings.ts b/src/lib/dtos/embedding-settings.ts new file mode 100644 index 0000000..f35c85f --- /dev/null +++ b/src/lib/dtos/embedding-settings.ts @@ -0,0 +1,41 @@ +import type { EmbeddingProviderKind } from '$lib/types'; + +export interface EmbeddingProfileConfigEntryDto { + key: string; + value: string; + redacted: boolean; +} + +export interface EmbeddingProfileDto { + id: string; + providerKind: string; + title: string; + enabled: boolean; + isDefault: boolean; + model: string; + dimensions: number; + config: RecordConfigure TrueRef embedding and indexing options
- ++ This is the profile used for semantic indexing and retrieval right now. +
+ + {#if activeProfile} +{activeProfile.title}
+Profile ID: {activeProfile.id}
+Provider configuration
++ These are the provider-specific settings currently saved for the active profile. +
+ + {#if activeConfigEntries.length > 0} ++ No provider-specific configuration is stored for this profile. +
++ For OpenAI-compatible profiles, edit the + settings in the Embedding Provider form + below. The built-in Local Model profile + does not currently expose extra configurable fields. +
+ {/if} +Profiles stored in the database and available for activation.
+{profile.title}
+{profile.id}
+Embeddings enable semantic search. Without them, only keyword search (FTS5) is used.
- {#if loading} -Loading current configuration…
- {:else} -