From 417c6fd072a63264bbda9acafb9651fd0815f538 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sat, 28 Mar 2026 10:03:44 +0100 Subject: [PATCH] fix(MULTIVERSION-0001): fix version indexing pipeline state and UI reactivity - Add updateVersion() helper to IndexingPipeline that writes to repository_versions - Set version state to indexing/indexed/error at the appropriate pipeline stages - Add computeVersionStats() to count snippets for a specific version - Replace Map with Record for activeVersionJobs to fix Svelte 5 reactivity edge cases - Remove premature loadVersions() call from handleIndexVersion (oncomplete fires it instead) - Add refreshRepo() to version oncomplete callback so stat badges update after indexing - Disable Index button when activeVersionJobs has an entry for that tag (not just version.state) - Add three pipeline test cases covering versionId indexing, error, and no-touch paths Co-Authored-By: Claude Sonnet 4.6 --- .../server/pipeline/indexing.pipeline.test.ts | 89 ++++++++++++++- src/lib/server/pipeline/indexing.pipeline.ts | 33 ++++++ src/routes/repos/[id]/+page.svelte | 105 +++++++++++++----- 3 files changed, 197 insertions(+), 30 deletions(-) diff --git a/src/lib/server/pipeline/indexing.pipeline.test.ts b/src/lib/server/pipeline/indexing.pipeline.test.ts index 5d3405c..a37b756 100644 --- a/src/lib/server/pipeline/indexing.pipeline.test.ts +++ b/src/lib/server/pipeline/indexing.pipeline.test.ts @@ -75,6 +75,28 @@ function insertRepo(db: Database.Database, overrides: Partial> = {} +): string { + const id = crypto.randomUUID(); + db.prepare( + `INSERT INTO repository_versions + (id, repository_id, tag, title, state, total_snippets, indexed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + overrides.id ?? id, + overrides.repository_id ?? '/test/repo', + overrides.tag ?? 'v1.0.0', + overrides.title ?? null, + overrides.state ?? 'pending', + overrides.total_snippets ?? 0, + overrides.indexed_at ?? null, + overrides.created_at ?? now + ); + return (overrides.id as string) ?? id; +} + function insertJob( db: Database.Database, overrides: Partial> = {} @@ -272,8 +294,12 @@ describe('IndexingPipeline', () => { ); } - function makeJob(repositoryId = '/test/repo') { - const jobId = insertJob(db, { repository_id: repositoryId, status: 'queued' }); + function makeJob(repositoryId = '/test/repo', versionId?: string) { + const jobId = insertJob(db, { + repository_id: repositoryId, + version_id: versionId ?? null, + status: 'queued' + }); return db.prepare(`SELECT * FROM indexing_jobs WHERE id = ?`).get(jobId) as { id: string; repositoryId?: string; @@ -644,4 +670,63 @@ describe('IndexingPipeline', () => { expect(finalJob.status).toBe('done'); expect(finalJob.progress).toBe(100); }); + + it('updates repository_versions state to indexing then indexed when job has versionId', async () => { + const versionId = insertVersion(db, { tag: 'v1.0.0', state: 'pending' }); + const files = [ + { + path: 'README.md', + content: '# Hello\n\nThis is documentation.', + sha: 'sha-readme', + language: 'markdown' + } + ]; + const pipeline = makePipeline({ files, totalFiles: 1 }); + const job = makeJob('/test/repo', versionId); + + await pipeline.run(job as never); + + const version = db + .prepare(`SELECT state, total_snippets, indexed_at FROM repository_versions WHERE id = ?`) + .get(versionId) as { state: string; total_snippets: number; indexed_at: number | null }; + + expect(version.state).toBe('indexed'); + expect(version.total_snippets).toBeGreaterThan(0); + expect(version.indexed_at).not.toBeNull(); + }); + + it('updates repository_versions state to error when pipeline throws and job has versionId', async () => { + const versionId = insertVersion(db, { tag: 'v1.0.0', state: 'pending' }); + const errorCrawl = vi.fn().mockRejectedValue(new Error('crawl failed')); + const pipeline = new IndexingPipeline( + db, + errorCrawl as never, + { crawl: errorCrawl } as never, + null + ); + const job = makeJob('/test/repo', versionId); + + await expect(pipeline.run(job as never)).rejects.toThrow('crawl failed'); + + const version = db + .prepare(`SELECT state FROM repository_versions WHERE id = ?`) + .get(versionId) as { state: string }; + + expect(version.state).toBe('error'); + }); + + it('does not touch repository_versions when job has no versionId', async () => { + const versionId = insertVersion(db, { tag: 'v1.0.0', state: 'pending' }); + const pipeline = makePipeline({ files: [], totalFiles: 0 }); + const job = makeJob('/test/repo'); // no versionId + + await pipeline.run(job as never); + + const version = db + .prepare(`SELECT state FROM repository_versions WHERE id = ?`) + .get(versionId) as { state: string }; + + // State should remain 'pending' — pipeline with no versionId must not touch it + expect(version.state).toBe('pending'); + }); }); diff --git a/src/lib/server/pipeline/indexing.pipeline.ts b/src/lib/server/pipeline/indexing.pipeline.ts index 8e8c677..61eb00d 100644 --- a/src/lib/server/pipeline/indexing.pipeline.ts +++ b/src/lib/server/pipeline/indexing.pipeline.ts @@ -90,6 +90,9 @@ export class IndexingPipeline { // Mark repo as actively indexing. this.updateRepo(repo.id, { state: 'indexing' }); + if (normJob.versionId) { + this.updateVersion(normJob.versionId, { state: 'indexing' }); + } // ---- Stage 1: Crawl ------------------------------------------------- const crawlResult = await this.crawl(repo); @@ -229,6 +232,15 @@ export class IndexingPipeline { lastIndexedAt: Math.floor(Date.now() / 1000) }); + if (normJob.versionId) { + const versionStats = this.computeVersionStats(normJob.versionId); + this.updateVersion(normJob.versionId, { + state: 'indexed', + totalSnippets: versionStats.totalSnippets, + indexedAt: Math.floor(Date.now() / 1000) + }); + } + this.updateJob(job.id, { status: 'done', progress: 100, @@ -246,6 +258,9 @@ export class IndexingPipeline { // Restore repo to error state but preserve any existing indexed data. this.updateRepo(repositoryId, { state: 'error' }); + if (normJob.versionId) { + this.updateVersion(normJob.versionId, { state: 'error' }); + } throw error; } @@ -384,6 +399,16 @@ export class IndexingPipeline { }; } + private computeVersionStats(versionId: string): { totalSnippets: number } { + const row = this.db + .prepare<[string], { total_snippets: number }>( + `SELECT COUNT(*) as total_snippets FROM snippets WHERE version_id = ?` + ) + .get(versionId); + + return { totalSnippets: row?.total_snippets ?? 0 }; + } + // ------------------------------------------------------------------------- // Private — DB helpers // ------------------------------------------------------------------------- @@ -433,6 +458,14 @@ export class IndexingPipeline { const values = [...Object.values(allFields), id]; this.db.prepare(`UPDATE repositories SET ${sets} WHERE id = ?`).run(...values); } + + private updateVersion(id: string, fields: Record): void { + const sets = Object.keys(fields) + .map((k) => `${toSnake(k)} = ?`) + .join(', '); + const values = [...Object.values(fields), id]; + this.db.prepare(`UPDATE repository_versions SET ${sets} WHERE id = ?`).run(...values); + } } // --------------------------------------------------------------------------- diff --git a/src/routes/repos/[id]/+page.svelte b/src/routes/repos/[id]/+page.svelte index cdb1b5a..08efac1 100644 --- a/src/routes/repos/[id]/+page.svelte +++ b/src/routes/repos/[id]/+page.svelte @@ -52,6 +52,9 @@ let showDiscoverPanel = $state(false); let registerBusy = $state(false); + // Active version indexing jobs: tag -> jobId + let activeVersionJobs = $state>({}); + // Remove confirm let removeTag = $state(null); @@ -115,6 +118,16 @@ activeJobId = d.job.id; } const versionCount = d.versionJobs?.length ?? 0; + if (versionCount > 0) { + let next = { ...activeVersionJobs }; + for (const vj of d.versionJobs) { + const matched = versions.find((v) => v.id === vj.versionId); + if (matched) { + next = { ...next, [matched.tag]: vj.id }; + } + } + activeVersionJobs = next; + } successMessage = versionCount > 0 ? `Re-indexing started. Also queued ${versionCount} version job${versionCount === 1 ? '' : 's'}.` @@ -157,6 +170,10 @@ const d = await res.json(); throw new Error(d.error ?? 'Failed to add version'); } + const d = await res.json(); + if (d.job?.id) { + activeVersionJobs = { ...activeVersionJobs, [tag]: d.job.id }; + } addVersionTag = ''; await loadVersions(); } catch (e) { @@ -177,7 +194,10 @@ const d = await res.json(); throw new Error(d.error ?? 'Failed to queue version indexing'); } - await loadVersions(); + const d = await res.json(); + if (d.job?.id) { + activeVersionJobs = { ...activeVersionJobs, [tag]: d.job.id }; + } } catch (e) { errorMessage = (e as Error).message; } @@ -244,8 +264,9 @@ registerBusy = true; errorMessage = null; try { - await Promise.all( - [...selectedDiscoveredTags].map((tag) => + const tags = [...selectedDiscoveredTags]; + const responses = await Promise.all( + tags.map((tag) => fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}/versions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -253,6 +274,15 @@ }) ) ); + const results = await Promise.all(responses.map((r) => (r.ok ? r.json() : null))); + let next = { ...activeVersionJobs }; + for (let i = 0; i < tags.length; i++) { + const result = results[i]; + if (result?.job?.id) { + next = { ...next, [tags[i]]: result.job.id }; + } + } + activeVersionJobs = next; showDiscoverPanel = false; discoveredTags = []; selectedDiscoveredTags = new Set(); @@ -346,7 +376,13 @@ {#if activeJobId}

Indexing in progress

- + { + activeJobId = null; + refreshRepo(); + }} + />
{:else if repo.state === 'error'}
@@ -461,31 +497,44 @@ {:else}
{#each versions as version (version.id)} -
-
- {version.tag} - - {stateLabels[version.state] ?? version.state} - -
-
- - +
+
+
+ {version.tag} + + {stateLabels[version.state] ?? version.state} + +
+
+ + +
+ {#if !!activeVersionJobs[version.tag]} + { + const { [version.tag]: _, ...rest } = activeVersionJobs; + activeVersionJobs = rest; + loadVersions(); + refreshRepo(); + }} + /> + {/if}
{/each}