Compare commits
3 Commits
0752636847
...
b386904303
| Author | SHA1 | Date | |
|---|---|---|---|
| b386904303 | |||
|
|
f86be4106b | ||
|
|
9525c58e9a |
20
README.md
20
README.md
@@ -214,16 +214,16 @@ For GitHub repositories, TrueRef fetches the file from the default branch root.
|
|||||||
|
|
||||||
### Fields
|
### Fields
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|---|---|---|---|
|
| ------------------ | -------- | -------- | ------------------------------------------------------------------------------------------------- |
|
||||||
| `$schema` | string | No | URL to the live JSON Schema for editor validation |
|
| `$schema` | string | No | URL to the live JSON Schema for editor validation |
|
||||||
| `projectTitle` | string | No | Display name override (max 100 chars) |
|
| `projectTitle` | string | No | Display name override (max 100 chars) |
|
||||||
| `description` | string | No | Library description used for search ranking (10–500 chars) |
|
| `description` | string | No | Library description used for search ranking (10–500 chars) |
|
||||||
| `folders` | string[] | No | Path prefixes or regex strings to **include** (max 50 items). If absent, all folders are included |
|
| `folders` | string[] | No | Path prefixes or regex strings to **include** (max 50 items). If absent, all folders are included |
|
||||||
| `excludeFolders` | string[] | No | Path prefixes or regex strings to **exclude** after the `folders` allowlist (max 50 items) |
|
| `excludeFolders` | string[] | No | Path prefixes or regex strings to **exclude** after the `folders` allowlist (max 50 items) |
|
||||||
| `excludeFiles` | string[] | No | Exact filenames to skip — no path, no glob (max 100 items) |
|
| `excludeFiles` | string[] | No | Exact filenames to skip — no path, no glob (max 100 items) |
|
||||||
| `rules` | string[] | No | Best-practice rules prepended to every `query-docs` response (max 20 rules, 5–500 chars each) |
|
| `rules` | string[] | No | Best-practice rules prepended to every `query-docs` response (max 20 rules, 5–500 chars each) |
|
||||||
| `previousVersions` | object[] | No | Version tags to register when the repository is indexed (max 50 entries) |
|
| `previousVersions` | object[] | No | Version tags to register when the repository is indexed (max 50 entries) |
|
||||||
|
|
||||||
`previousVersions` entries each require a `tag` (e.g. `"v1.2.3"`) and a `title` (e.g. `"Version 1.2.3"`).
|
`previousVersions` entries each require a `tag` (e.g. `"v1.2.3"`) and a `title` (e.g. `"Version 1.2.3"`).
|
||||||
|
|
||||||
|
|||||||
@@ -1,171 +1,168 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
Last Updated: 2026-03-30T00:00:00.000Z
|
Last Updated: 2026-04-01T12:05:23.000Z
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
TrueRef is a TypeScript-first, self-hosted documentation retrieval platform built on SvelteKit. The repository contains a Node-targeted web application, a REST API, a Model Context Protocol server, and a multi-threaded server-side indexing pipeline backed by SQLite via better-sqlite3 and Drizzle ORM.
|
TrueRef is a TypeScript-first, self-hosted documentation retrieval platform built on SvelteKit. The repository contains a Node-targeted web application, a REST API, a Model Context Protocol server, and a worker-threaded indexing pipeline backed by SQLite via better-sqlite3, Drizzle ORM, FTS5, and sqlite-vec.
|
||||||
|
|
||||||
- Primary language: TypeScript (141 files) with a small amount of JavaScript configuration (2 files)
|
- Primary language: TypeScript (147 `.ts` files) with a small amount of JavaScript configuration and build code (2 `.js` files), excluding generated output and dependencies
|
||||||
- Application type: Full-stack SvelteKit application with worker-threaded indexing and retrieval services
|
- Application type: Full-stack SvelteKit application with server-side indexing, retrieval, and MCP integration
|
||||||
- Runtime framework: SvelteKit with adapter-node
|
- Runtime framework: SvelteKit with adapter-node
|
||||||
- Storage: SQLite (WAL mode) with Drizzle-managed schema plus hand-written FTS5 setup
|
- Storage: SQLite in WAL mode with Drizzle-managed relational schema, FTS5 full-text indexes, and sqlite-vec virtual tables for vector lookup
|
||||||
- Concurrency: Node.js worker_threads for parse and embedding work
|
- Concurrency: Node.js `worker_threads` for parse, embed, and auxiliary write-worker infrastructure
|
||||||
- Testing: Vitest with separate client and server projects
|
- Testing: Vitest for unit and integration coverage
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
- src/routes: SvelteKit pages and HTTP endpoints, including the public UI and /api/v1 surface
|
- `src/routes`: SvelteKit pages and HTTP endpoints, including the public UI and `/api/v1` surface
|
||||||
- src/lib/server: Backend implementation grouped by concern: api, config, crawler, db, embeddings, mappers, models, parser, pipeline, search, services, utils
|
- `src/lib/server`: Backend implementation grouped by concern: `api`, `config`, `crawler`, `db`, `embeddings`, `mappers`, `models`, `parser`, `pipeline`, `search`, `services`, `utils`
|
||||||
- src/mcp: Standalone MCP server entry point and tool handlers
|
- `src/mcp`: Standalone MCP server entry point, client, tests, and tool handlers
|
||||||
- static: Static assets such as robots.txt
|
- `scripts`: Build helpers, including worker bundling
|
||||||
- docs/features: Feature-level implementation notes and product documentation
|
- `static`: Static assets such as `robots.txt`
|
||||||
- build: Generated SvelteKit output
|
- `docs/features`: Feature-level implementation notes and product documentation
|
||||||
|
- `build`: Generated SvelteKit output and bundled worker entrypoints
|
||||||
|
|
||||||
## Key Directories
|
## Key Directories
|
||||||
|
|
||||||
### src/routes
|
### `src/routes`
|
||||||
|
|
||||||
Contains the UI entry points and API routes. The API tree under src/routes/api/v1 is the public HTTP contract for repository management, indexing jobs, search/context retrieval, settings, filesystem browsing, JSON schema discovery, real-time SSE progress streaming, and job control (pause/resume/cancel).
|
Contains the UI entry points and API routes. The API tree under `src/routes/api/v1` is the public HTTP contract for repository management, version discovery, indexing jobs, search/context retrieval, embedding settings, indexing settings, filesystem browsing, worker-status inspection, and SSE progress streaming.
|
||||||
|
|
||||||
### src/lib/server/db
|
### `src/lib/server/db`
|
||||||
|
|
||||||
Owns SQLite schema definitions, migration bootstrapping, and FTS initialization. Database startup runs through initializeDatabase(), which executes Drizzle migrations and then applies FTS5 SQL that cannot be expressed directly in the ORM.
|
Owns SQLite schema definitions, relational migrations, connection bootstrapping, and sqlite-vec loading. Database startup goes through `initializeDatabase()` and `getClient()`, both of which configure WAL-mode pragmas and ensure sqlite-vec is loaded on each connection before vector-backed queries run.
|
||||||
|
|
||||||
### src/lib/server/pipeline
|
### `src/lib/server/search`
|
||||||
|
|
||||||
Coordinates crawl, parse, chunk, store, and optional embedding generation work using a worker thread pool. The pipeline module consists of:
|
Implements keyword, vector, and hybrid retrieval. Keyword search uses SQLite FTS5 and BM25-style ranking. Vector search uses `SqliteVecStore` to maintain per-profile sqlite-vec `vec0` tables plus rowid mapping tables, and hybrid search blends FTS and vector candidates through reciprocal rank fusion.
|
||||||
|
|
||||||
- **WorkerPool** (`worker-pool.ts`): Manages a configurable number of Node.js `worker_threads` for parse jobs and an optional dedicated embed worker. Dispatches jobs round-robin to idle workers, enforces per-repository serialisation (one active job per repo), auto-respawns crashed workers, and supports runtime concurrency adjustment via `setMaxConcurrency()`. Falls back to main-thread execution when worker scripts are not found.
|
### `src/lib/server/pipeline`
|
||||||
- **Parse worker** (`worker-entry.ts`): Runs in a worker thread. Opens its own `better-sqlite3` connection (WAL mode, `busy_timeout = 5000`), constructs a local `IndexingPipeline` instance, and processes jobs by posting `progress`, `done`, or `failed` messages back to the parent.
|
|
||||||
- **Embed worker** (`embed-worker-entry.ts`): Dedicated worker for embedding generation. Loads the embedding profile from the database, creates an `EmbeddingService`, and processes embed requests after the parse worker finishes a job.
|
|
||||||
- **ProgressBroadcaster** (`progress-broadcaster.ts`): Server-side pub/sub for real-time SSE streaming. Supports per-job, per-repository, and global subscriptions. Caches the last event per job for reconnect support.
|
|
||||||
- **Worker types** (`worker-types.ts`): Shared TypeScript discriminated union types for `ParseWorkerRequest`/`ParseWorkerResponse` and `EmbedWorkerRequest`/`EmbedWorkerResponse` message protocols.
|
|
||||||
- **Startup** (`startup.ts`): Recovers stale jobs, constructs singleton `JobQueue`, `IndexingPipeline`, `WorkerPool`, and `ProgressBroadcaster` instances, reads concurrency settings from the database, and drains queued work after restart.
|
|
||||||
- **JobQueue** (`job-queue.ts`): SQLite-backed queue that delegates to the `WorkerPool` when available, with pause/resume/cancel support.
|
|
||||||
|
|
||||||
### src/lib/server/search
|
Coordinates crawl, diff, parse, store, embed, and job-state broadcasting. The pipeline module consists of:
|
||||||
|
|
||||||
Implements keyword, vector, and hybrid retrieval. The keyword path uses SQLite FTS5 and BM25; the hybrid path blends FTS and vector search with reciprocal rank fusion.
|
- `IndexingPipeline`: orchestrates crawl, diff, parse, transactional replacement, optional embedding generation, and repository statistics updates
|
||||||
|
- `WorkerPool`: manages parse workers, an optional embed worker, an optional write worker, per-repository-and-version serialization, worker respawn, and runtime concurrency changes
|
||||||
|
- `worker-entry.ts`: parse worker that opens its own `better-sqlite3` connection, runs the indexing pipeline, and reports progress back to the parent
|
||||||
|
- `embed-worker-entry.ts`: embedding worker that loads the active profile, creates an `EmbeddingService`, and generates vectors after parse completion
|
||||||
|
- `write-worker-entry.ts`: batch-write worker with a `write`/`write_ack`/`write_error` message protocol for document and snippet persistence
|
||||||
|
- `progress-broadcaster.ts`: server-side pub/sub for per-job, per-repository, global, and worker-status SSE streams
|
||||||
|
- `startup.ts`: recovers stale jobs, constructs singleton queue/pipeline/pool/broadcaster instances, loads concurrency settings, and drains queued work after restart
|
||||||
|
- `worker-types.ts`: shared TypeScript discriminated unions for parse, embed, and write worker protocols
|
||||||
|
|
||||||
### src/lib/server/crawler and src/lib/server/parser
|
### `src/lib/server/crawler` and `src/lib/server/parser`
|
||||||
|
|
||||||
Convert GitHub repositories and local folders into normalized snippet records. Crawlers fetch repository contents, parsers split Markdown, code, config, HTML-like, and plain-text files into chunks, and downstream services persist searchable content.
|
Convert GitHub repositories and local folders into normalized snippet records. Crawlers fetch repository contents and configuration, parsers split Markdown, code, config, HTML-like, and plain-text files into searchable snippet records, and downstream services persist searchable content and embeddings.
|
||||||
|
|
||||||
### src/mcp
|
### `src/mcp`
|
||||||
|
|
||||||
Provides a thin compatibility layer over the HTTP API. The MCP server exposes resolve-library-id and query-docs over stdio or HTTP and forwards work to local tool handlers.
|
Provides a thin compatibility layer over the HTTP API. The MCP server exposes `resolve-library-id` and `query-docs` over stdio or HTTP and forwards work to local handlers that reuse the application retrieval stack.
|
||||||
|
|
||||||
## Design Patterns
|
## Design Patterns
|
||||||
|
|
||||||
- The WorkerPool implements an **observer/callback pattern**: the pool owner provides `onProgress`, `onJobDone`, `onJobFailed`, `onEmbedDone`, and `onEmbedFailed` callbacks at construction time, and the pool invokes them when workers post messages.
|
- **Service layer**: business logic lives in classes such as `RepositoryService`, `VersionService`, `SearchService`, `HybridSearchService`, and `EmbeddingService`
|
||||||
- ProgressBroadcaster implements a **pub/sub pattern** with three subscription tiers (per-job, per-repository, global) and last-event caching for SSE reconnect.
|
- **Factory pattern**: embedding providers are created from persisted profile records through registry/factory helpers
|
||||||
- The implementation consistently uses **service classes** such as RepositoryService, SearchService, and HybridSearchService for business logic.
|
- **Mapper/entity separation**: mappers translate between raw database rows and domain entities such as `RepositoryEntity`, `RepositoryVersionEntity`, and `EmbeddingProfileEntity`
|
||||||
- Mapping and entity layers separate raw database rows from domain objects through **mapper/entity pairs** such as RepositoryMapper and RepositoryEntity.
|
- **Module-level singletons**: pipeline startup owns lifecycle for `JobQueue`, `IndexingPipeline`, `WorkerPool`, and `ProgressBroadcaster`, with accessor functions for route handlers
|
||||||
- Pipeline startup uses **module-level singletons** for JobQueue, IndexingPipeline, WorkerPool, and ProgressBroadcaster lifecycle management, with accessor functions (getQueue, getPool, getBroadcaster) for route handlers.
|
- **Pub/sub**: `ProgressBroadcaster` maintains job, repository, global, and worker-status subscriptions for SSE delivery
|
||||||
- Worker message protocols use **TypeScript discriminated unions** (`type` field) for type-safe worker ↔ parent communication.
|
- **Discriminated unions**: worker message protocols use a `type` field for type-safe parent/worker communication
|
||||||
|
|
||||||
## Key Components
|
## Key Components
|
||||||
|
|
||||||
### SvelteKit server bootstrap
|
### SvelteKit server bootstrap
|
||||||
|
|
||||||
src/hooks.server.ts initializes the database, loads persisted embedding configuration, creates the optional EmbeddingService, reads indexing concurrency settings from the database, starts the indexing pipeline with WorkerPool and ProgressBroadcaster via `initializePipeline(db, embeddingService, { concurrency, dbPath })`, and applies CORS headers to all /api routes.
|
`src/hooks.server.ts` initializes the relational database, opens the shared raw SQLite client, loads the default embedding profile, creates the optional `EmbeddingService`, reads indexing concurrency from the `settings` table, and initializes the queue/pipeline/worker infrastructure.
|
||||||
|
|
||||||
### Database layer
|
### Database layer
|
||||||
|
|
||||||
src/lib/server/db/schema.ts defines repositories, repository_versions, documents, snippets, embedding_profiles, snippet_embeddings, indexing_jobs, repository_configs, and settings. This schema models the indexed library catalog, retrieval corpus, embedding state, and job tracking.
|
`src/lib/server/db/schema.ts` defines repositories, repository versions, documents, snippets, embedding profiles, relational embedding metadata, indexing jobs, repository configs, and generic settings. Relational embedding rows keep canonical model metadata and raw float buffers, while sqlite-vec virtual tables are managed separately per profile through `SqliteVecStore`.
|
||||||
|
|
||||||
|
### sqlite-vec integration
|
||||||
|
|
||||||
|
`src/lib/server/db/sqlite-vec.ts` centralizes sqlite-vec loading and deterministic per-profile table naming. `SqliteVecStore` creates `vec0` tables plus rowid mapping tables, backfills missing rows from `snippet_embeddings`, removes stale vector references, and executes nearest-neighbor queries constrained by repository, optional version, and profile.
|
||||||
|
|
||||||
### Retrieval API
|
### Retrieval API
|
||||||
|
|
||||||
src/routes/api/v1/context/+server.ts validates input, resolves repository and optional version IDs, chooses keyword, semantic, or hybrid retrieval, applies token budgeting that skips oversized snippets instead of stopping early, prepends repository rules, and formats JSON or text responses with repository and version metadata.
|
`src/routes/api/v1/context/+server.ts` validates input, resolves repository and optional version scope, chooses keyword, semantic, or hybrid retrieval, applies token budgeting, and formats JSON or text responses. `/api/v1/libs/search` handles repository-level lookup, while MCP tool handlers expose the same retrieval behavior over stdio or HTTP transports.
|
||||||
|
|
||||||
### Search engine
|
### Search engine
|
||||||
|
|
||||||
src/lib/server/search/search.service.ts preprocesses raw user input into FTS5-safe MATCH expressions before keyword search and repository lookup. src/lib/server/search/hybrid.search.service.ts supports explicit keyword, semantic, and hybrid modes, falls back to vector retrieval when FTS yields no candidates and an embedding provider is configured, and uses reciprocal rank fusion for blended ranking.
|
`SearchService` preprocesses raw user input into FTS5-safe expressions before keyword search. `HybridSearchService` supports explicit keyword, semantic, and hybrid modes, falls back to vector retrieval when keyword search yields no candidates and an embedding provider is configured, and uses reciprocal rank fusion to merge ranked lists. `VectorSearch` delegates KNN execution to `SqliteVecStore` instead of doing brute-force in-memory cosine scoring.
|
||||||
|
|
||||||
### Repository management
|
### Repository and version management
|
||||||
|
|
||||||
src/lib/server/services/repository.service.ts provides CRUD and statistics for indexed repositories, including canonical ID generation for GitHub and local sources.
|
`RepositoryService` and `VersionService` provide CRUD, indexing-status, cleanup, and statistics logic for indexed repositories and tagged versions, including sqlite-vec cleanup when repository-scoped or version-scoped content is removed.
|
||||||
|
|
||||||
### MCP surface
|
### Worker-threaded indexing
|
||||||
|
|
||||||
src/mcp/index.ts creates the MCP server, registers the two supported tools, and exposes them over stdio or streamable HTTP.
|
The active indexing path is parse-worker-first: queued jobs are dispatched to parse workers, progress is written to SQLite and broadcast over SSE, and successful parse completion can enqueue embedding work on the dedicated embed worker. The worker pool also exposes status snapshots through `/api/v1/workers`. Write-worker infrastructure exists in the current architecture and is bundled at build time, but parse/embed flow remains the primary live path described by `IndexingPipeline` and `WorkerPool`.
|
||||||
|
|
||||||
### Worker thread pool
|
### SSE streaming and job control
|
||||||
|
|
||||||
src/lib/server/pipeline/worker-pool.ts manages a pool of Node.js worker threads. Parse workers run the full crawl → parse → store pipeline inside isolated threads with their own better-sqlite3 connections (WAL mode enables concurrent readers). An optional embed worker handles embedding generation in a separate thread. The pool enforces per-repository serialisation, auto-respawns crashed workers, and supports runtime concurrency changes persisted through the settings table.
|
`progress-broadcaster.ts` provides real-time Server-Sent Event streaming of indexing progress. Route handlers under `/api/v1/jobs/stream` and `/api/v1/jobs/[id]/stream` expose SSE endpoints, and `/api/v1/workers` exposes worker-pool status. Job control endpoints support pause, resume, and cancel transitions backed by SQLite job state.
|
||||||
|
|
||||||
### SSE streaming
|
|
||||||
|
|
||||||
src/lib/server/pipeline/progress-broadcaster.ts provides real-time Server-Sent Event streaming of indexing progress. Route handlers in src/routes/api/v1/jobs/stream and src/routes/api/v1/jobs/[id]/stream expose SSE endpoints. The broadcaster supports per-job, per-repository, and global subscriptions, with last-event caching for reconnect via the `Last-Event-ID` header.
|
|
||||||
|
|
||||||
### Job control
|
|
||||||
|
|
||||||
src/routes/api/v1/jobs/[id]/pause, resume, and cancel endpoints allow runtime control of indexing jobs. The JobQueue supports pause/resume/cancel state transitions persisted to SQLite.
|
|
||||||
|
|
||||||
### Indexing settings
|
### Indexing settings
|
||||||
|
|
||||||
src/routes/api/v1/settings/indexing exposes GET and PUT for indexing concurrency. PUT validates and clamps the value to `max(cpus - 1, 1)`, persists it to the settings table, and live-updates the WorkerPool via `setMaxConcurrency()`.
|
`/api/v1/settings/indexing` exposes GET and PUT for indexing concurrency. The value is persisted in the `settings` table and applied live to the `WorkerPool` through `setMaxConcurrency()`.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
|
|
||||||
- @modelcontextprotocol/sdk: MCP server transport and protocol types
|
- `@modelcontextprotocol/sdk`: MCP server transport and protocol types
|
||||||
- @xenova/transformers: local embedding support
|
- `@xenova/transformers`: local embedding support
|
||||||
- better-sqlite3: synchronous SQLite driver
|
- `better-sqlite3`: synchronous SQLite driver used by the main app and workers
|
||||||
- zod: runtime input validation for MCP tools and server helpers
|
- `sqlite-vec`: SQLite vector extension used for `vec0` storage and nearest-neighbor queries
|
||||||
|
- `zod`: runtime validation for MCP tools and server helpers
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
- @sveltejs/kit and @sveltejs/adapter-node: application framework and Node deployment target
|
- `@sveltejs/kit` and `@sveltejs/adapter-node`: application framework and Node deployment target
|
||||||
- drizzle-kit and drizzle-orm: schema management and typed database access
|
- `drizzle-kit` and `drizzle-orm`: schema management and typed database access
|
||||||
- esbuild: worker thread entry point bundling (build/workers/)
|
- `esbuild`: worker entrypoint bundling into `build/workers`
|
||||||
- vite and @tailwindcss/vite: bundling and Tailwind integration
|
- `vite` and `@tailwindcss/vite`: application bundling and Tailwind integration
|
||||||
- vitest and @vitest/browser-playwright: server and browser test execution
|
- `vitest` and `@vitest/browser-playwright`: server and browser test execution
|
||||||
- eslint, typescript-eslint, eslint-plugin-svelte, prettier, prettier-plugin-svelte, prettier-plugin-tailwindcss: linting and formatting
|
- `eslint`, `typescript-eslint`, `eslint-plugin-svelte`, `prettier`, `prettier-plugin-svelte`, `prettier-plugin-tailwindcss`: linting and formatting
|
||||||
- typescript and @types/node: type-checking and Node typings
|
- `typescript` and `@types/node`: type-checking and Node typings
|
||||||
|
|
||||||
## Module Organization
|
## Module Organization
|
||||||
|
|
||||||
The backend is organized by responsibility rather than by route. HTTP handlers in src/routes/api/v1 are intentionally thin and delegate to library modules in src/lib/server. Within src/lib/server, concerns are separated into:
|
The backend is organized by responsibility rather than by route. HTTP handlers under `src/routes/api/v1` are intentionally thin and delegate to modules in `src/lib/server`. Within `src/lib/server`, concerns are separated into:
|
||||||
|
|
||||||
- models and mappers for entity translation
|
- `models` and `mappers` for entity translation
|
||||||
- services for repository/version operations
|
- `services` for repository/version operations
|
||||||
- search for retrieval strategies
|
- `search` for keyword, vector, and hybrid retrieval strategies
|
||||||
- crawler and parser for indexing input transformation
|
- `crawler` and `parser` for indexing input transformation
|
||||||
- pipeline for orchestration and job execution
|
- `pipeline` for orchestration, workers, and job execution
|
||||||
- embeddings for provider abstraction and embedding generation
|
- `embeddings` for provider abstraction and vector generation
|
||||||
- api and utils for response formatting, validation, and shared helpers
|
- `db`, `api`, `config`, and `utils` for persistence, response formatting, validation, and shared helpers
|
||||||
|
|
||||||
The frontend and backend share the same SvelteKit repository, but most non-UI behavior is implemented on the server side.
|
The frontend and backend live in the same SvelteKit repository, but most non-UI behavior is implemented on the server side.
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
### Indexing flow
|
### Indexing flow
|
||||||
|
|
||||||
1. Server startup runs initializeDatabase() and initializePipeline() from src/hooks.server.ts, which creates the WorkerPool, ProgressBroadcaster, and JobQueue singletons.
|
1. Server startup runs database initialization, opens the shared client, loads sqlite-vec, and initializes the pipeline singletons.
|
||||||
2. The pipeline recovers stale jobs (marks running → failed, indexing → error), reads concurrency settings, and resumes queued work.
|
2. Startup recovery marks interrupted jobs as failed, resets repositories stuck in `indexing`, reads persisted concurrency settings, and drains queued jobs.
|
||||||
3. When a job is enqueued, the JobQueue delegates to the WorkerPool, which dispatches work to an idle parse worker thread.
|
3. `JobQueue` dispatches eligible work to the `WorkerPool`, which serializes by `(repositoryId, versionId)` and posts jobs to idle parse workers.
|
||||||
4. Each parse worker opens its own better-sqlite3 connection (WAL mode) and runs the full crawl → parse → store pipeline, posting progress messages back to the parent thread.
|
4. Each parse worker opens its own SQLite connection, crawls the source, computes differential work, parses files into snippets, and persists replacement data through the indexing pipeline.
|
||||||
5. The parent thread updates job progress in the database and broadcasts SSE events through the ProgressBroadcaster.
|
5. The parent thread updates job progress in SQLite and broadcasts SSE progress and worker-status events.
|
||||||
6. On parse completion, if an embedding provider is configured, the WorkerPool enqueues an embed request to the dedicated embed worker, which generates vectors in its own thread.
|
6. If an embedding provider is configured, the completed parse job triggers embed work that stores canonical embedding blobs and synchronizes sqlite-vec profile tables for nearest-neighbor lookup.
|
||||||
7. Job control endpoints allow pausing, resuming, or cancelling jobs at runtime.
|
7. Repository/version statistics and job status are finalized in SQLite, and control endpoints can pause, resume, or cancel subsequent queued work.
|
||||||
|
|
||||||
### Retrieval flow
|
### Retrieval flow
|
||||||
|
|
||||||
1. Clients call /api/v1/libs/search, /api/v1/context, or the MCP tools.
|
1. Clients call `/api/v1/libs/search`, `/api/v1/context`, or the MCP tools.
|
||||||
2. Route handlers validate input and load the SQLite client.
|
2. Route handlers validate input and use the shared SQLite client.
|
||||||
3. Keyword search uses FTS5 via SearchService; hybrid search optionally adds vector results via HybridSearchService.
|
3. Keyword search uses FTS5 through `SearchService`; semantic search uses sqlite-vec KNN through `VectorSearch`; hybrid search merges both paths with reciprocal rank fusion.
|
||||||
4. Query preprocessing normalizes punctuation-heavy or code-like input before FTS search, while semantic mode bypasses FTS and auto or hybrid mode can fall back to vector retrieval when keyword search produces no candidates.
|
4. Retrieval is scoped by repository and optional version, and semantic/hybrid paths can fall back when keyword search yields no usable candidates.
|
||||||
5. Token budgeting walks ranked snippets in order and skips individual over-budget snippets so later matches can still be returned.
|
5. Token budgeting selects ranked snippets for the response formatter, which emits repository-aware JSON or text payloads.
|
||||||
6. Formatters emit repository and version metadata in JSON responses and origin-aware or explicit no-result text output for plain-text responses.
|
|
||||||
7. MCP handlers expose the same retrieval behavior over stdio or HTTP transports.
|
|
||||||
|
|
||||||
## Build System
|
## Build System
|
||||||
|
|
||||||
- Build command: npm run build (runs `vite build` then `node scripts/build-workers.mjs`)
|
- Build command: `npm run build` (runs `vite build` then `node scripts/build-workers.mjs`)
|
||||||
- Worker bundling: scripts/build-workers.mjs uses esbuild to compile worker-entry.ts and embed-worker-entry.ts into build/workers/ as ESM bundles (.mjs), with $lib path aliases resolved and better-sqlite3/@xenova/transformers marked external
|
- Worker bundling: `scripts/build-workers.mjs` uses esbuild to compile `worker-entry.ts`, `embed-worker-entry.ts`, and `write-worker-entry.ts` into `build/workers/` as ESM bundles
|
||||||
- Test command: npm run test
|
- Test command: `npm test`
|
||||||
- Primary local run command from package.json: npm run dev
|
- Primary local run command: `npm run dev`
|
||||||
- MCP entry points: npm run mcp:start and npm run mcp:http
|
- MCP entry points: `npm run mcp:start` and `npm run mcp:http`
|
||||||
|
|||||||
215
docs/FINDINGS.md
215
docs/FINDINGS.md
@@ -1,29 +1,28 @@
|
|||||||
# Findings
|
# Findings
|
||||||
|
|
||||||
Last Updated: 2026-03-30T00:00:00.000Z
|
Last Updated: 2026-04-01T12:05:23.000Z
|
||||||
|
|
||||||
## Initializer Summary
|
## Initializer Summary
|
||||||
|
|
||||||
- JIRA: TRUEREF-0022
|
- JIRA: TRUEREF-0023
|
||||||
- Refresh mode: REFRESH_IF_REQUIRED
|
- Refresh mode: REFRESH_IF_REQUIRED
|
||||||
- Result: Refreshed ARCHITECTURE.md and FINDINGS.md. CODE_STYLE.md remained trusted — new worker thread code follows established conventions.
|
- Result: Refreshed ARCHITECTURE.md and FINDINGS.md. CODE_STYLE.md remained trusted — sqlite-vec, worker-status, and write-worker additions follow the established conventions already documented.
|
||||||
|
|
||||||
## Research Performed
|
## Research Performed
|
||||||
|
|
||||||
- Discovered 141 TypeScript/JavaScript source files (up from 110), with new pipeline worker, broadcaster, and SSE endpoint files.
|
- Counted 149 TypeScript/JavaScript source files in the repository-wide scan and verified the live, non-generated source mix as 147 `.ts` files and 2 `.js` files.
|
||||||
- Read worker-pool.ts, worker-entry.ts, embed-worker-entry.ts, worker-types.ts, progress-broadcaster.ts, startup.ts, job-queue.ts to understand the new worker thread architecture.
|
- Read `package.json`, `.prettierrc`, and `eslint.config.js` to verify dependencies, formatting rules, and linting conventions.
|
||||||
- Read SSE endpoints (jobs/stream, jobs/[id]/stream) and job control endpoints (pause, resume, cancel).
|
- Read `sqlite-vec.ts`, `sqlite-vec.store.ts`, `vector.search.ts`, `hybrid.search.service.ts`, `schema.ts`, `client.ts`, and startup wiring to verify the accepted sqlite-vec implementation and current retrieval architecture.
|
||||||
- Read indexing settings endpoint and hooks.server.ts to verify startup wiring changes.
|
- Read `worker-pool.ts`, `worker-types.ts`, `write-worker-entry.ts`, and `/api/v1/workers/+server.ts` to verify the current worker topology and status surface.
|
||||||
- Read build-workers.mjs and package.json to verify build system and dependency changes.
|
- Compared `docs/docs_cache_state.yaml` against the live docs and codebase to identify stale cache evidence and architecture drift.
|
||||||
- Compared trusted cache state with current codebase to identify ARCHITECTURE.md as stale.
|
- Confirmed `CODE_STYLE.md` still matches the codebase: tabs, single quotes, `trailingComma: none`, ESM imports with `node:` built-ins, flat ESLint config, and descriptive PascalCase/camelCase naming remain consistent.
|
||||||
- Confirmed CODE_STYLE.md conventions still match the codebase — new code uses PascalCase classes, camelCase functions, tab indentation, ESM imports, and TypeScript discriminated unions consistent with existing style.
|
|
||||||
|
|
||||||
## Open Questions For Planner
|
## Open Questions For Planner
|
||||||
|
|
||||||
- Verify whether the retrieval response contract should document the new repository and version metadata fields formally in a public API reference beyond the architecture summary.
|
- Verify whether the write-worker protocol should become part of the active indexing flow or remain documented as optional infrastructure only.
|
||||||
- Verify whether parser chunking should evolve further from file-level and declaration-level boundaries to member-level semantic chunks for class-heavy codebases.
|
- Verify whether worker-status and SSE event payloads should be documented in a dedicated API reference for external consumers.
|
||||||
- Verify whether the SSE streaming contract (event names, data shapes) should be documented in a dedicated API reference for external consumers.
|
- Verify whether sqlite-vec operational details such as per-profile vec-table lifecycle and backfill behavior should move into a separate persistence document if the subsystem grows further.
|
||||||
- Assess whether the WorkerPool fallback mode (main-thread execution when worker scripts are missing) needs explicit test coverage or should be removed in favour of a hard build requirement.
|
- Assess whether the WorkerPool fallback mode (main-thread execution when worker scripts are missing) still belongs in the runtime contract or should be removed in favour of a hard build requirement.
|
||||||
|
|
||||||
## Planner Notes Template
|
## Planner Notes Template
|
||||||
|
|
||||||
@@ -37,6 +36,41 @@ Add subsequent research below this section.
|
|||||||
- Findings:
|
- Findings:
|
||||||
- Risks / follow-ups:
|
- Risks / follow-ups:
|
||||||
|
|
||||||
|
### 2026-04-01 — TRUEREF-0023 initializer refresh audit
|
||||||
|
|
||||||
|
- Task: Refresh only stale or invalid documentation after the accepted sqlite-vec implementation.
|
||||||
|
- Files inspected:
|
||||||
|
- `docs/docs_cache_state.yaml`
|
||||||
|
- `docs/ARCHITECTURE.md`
|
||||||
|
- `docs/CODE_STYLE.md`
|
||||||
|
- `docs/FINDINGS.md`
|
||||||
|
- `package.json`
|
||||||
|
- `.prettierrc`
|
||||||
|
- `eslint.config.js`
|
||||||
|
- `src/hooks.server.ts`
|
||||||
|
- `src/lib/server/db/client.ts`
|
||||||
|
- `src/lib/server/db/schema.ts`
|
||||||
|
- `src/lib/server/db/sqlite-vec.ts`
|
||||||
|
- `src/lib/server/search/sqlite-vec.store.ts`
|
||||||
|
- `src/lib/server/search/vector.search.ts`
|
||||||
|
- `src/lib/server/search/hybrid.search.service.ts`
|
||||||
|
- `src/lib/server/pipeline/startup.ts`
|
||||||
|
- `src/lib/server/pipeline/worker-pool.ts`
|
||||||
|
- `src/lib/server/pipeline/worker-types.ts`
|
||||||
|
- `src/lib/server/pipeline/write-worker-entry.ts`
|
||||||
|
- `src/routes/api/v1/workers/+server.ts`
|
||||||
|
- `scripts/build-workers.mjs`
|
||||||
|
- Findings:
|
||||||
|
- The trusted cache metadata was no longer reliable as evidence for planning: `docs/docs_cache_state.yaml` still referenced 2026-03-27 hashes while `ARCHITECTURE.md` and `FINDINGS.md` had been edited later.
|
||||||
|
- `ARCHITECTURE.md` was stale. It still described only parse and embed worker concurrency, omitted the `sqlite-vec` production dependency, and did not document the current per-profile vec-table storage layer, worker-status endpoint, or write-worker infrastructure.
|
||||||
|
- The current retrieval stack uses sqlite-vec concretely: `loadSqliteVec()` bootstraps connections, `SqliteVecStore` manages vec0 tables plus rowid mapping tables, and `VectorSearch` delegates nearest-neighbor lookup to that store instead of brute-force scoring.
|
||||||
|
- The worker architecture now includes parse, embed, and write worker protocols in `worker-types.ts`, build-time bundling for all three entries, and a `/api/v1/workers` route that returns `WorkerPool` status snapshots.
|
||||||
|
- `CODE_STYLE.md` remained valid and did not require refresh. The observed source and config files still use tabs, single quotes, `trailingComma: none`, flat ESLint config, ESM imports, PascalCase class names, and camelCase helpers exactly as already documented.
|
||||||
|
- `FINDINGS.md` itself was stale because the initializer summary still referred to `TRUEREF-0022` instead of the requested `TRUEREF-0023` refresh.
|
||||||
|
- Risks / follow-ups:
|
||||||
|
- The write-worker protocol exists and is bundled, but the active indexing path is still centered on parse plus optional embed flow. Future documentation should keep distinguishing implemented infrastructure from the currently exercised path.
|
||||||
|
- Cache validity should continue to be driven by deterministic hash evidence rather than document timestamps or trust text alone.
|
||||||
|
|
||||||
### 2026-03-27 — FEEDBACK-0001 initializer refresh audit
|
### 2026-03-27 — FEEDBACK-0001 initializer refresh audit
|
||||||
|
|
||||||
- Task: Refresh only stale documentation after changes to retrieval, formatters, token budgeting, and parser behavior.
|
- Task: Refresh only stale documentation after changes to retrieval, formatters, token budgeting, and parser behavior.
|
||||||
@@ -192,3 +226,156 @@ Add subsequent research below this section.
|
|||||||
- Risks / follow-ups:
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
### 2026-04-01 — TRUEREF-0023 sqlite-vec replanning research
|
||||||
|
|
||||||
|
- Task: Replan the rejected libSQL-native vector iteration around sqlite-vec using the current worktree and verified runtime constraints.
|
||||||
|
- Files inspected:
|
||||||
|
- `package.json`
|
||||||
|
- `docs/docs_cache_state.yaml`
|
||||||
|
- `prompts/TRUEREF-0023/prompt.yaml`
|
||||||
|
- `prompts/TRUEREF-0023/progress.yaml`
|
||||||
|
- `prompts/TRUEREF-0023/iteration_0/review_report.yaml`
|
||||||
|
- `prompts/TRUEREF-0023/iteration_0/plan.md`
|
||||||
|
- `prompts/TRUEREF-0023/iteration_0/tasks.yaml`
|
||||||
|
- `src/lib/server/db/client.ts`
|
||||||
|
- `src/lib/server/db/index.ts`
|
||||||
|
- `src/lib/server/db/schema.ts`
|
||||||
|
- `src/lib/server/db/fts.sql`
|
||||||
|
- `src/lib/server/db/vectors.sql`
|
||||||
|
- `src/lib/server/db/schema.test.ts`
|
||||||
|
- `src/lib/server/search/vector.search.ts`
|
||||||
|
- `src/lib/server/search/hybrid.search.service.test.ts`
|
||||||
|
- `src/lib/server/embeddings/embedding.service.ts`
|
||||||
|
- `src/lib/server/embeddings/embedding.service.test.ts`
|
||||||
|
- `src/lib/server/pipeline/job-queue.ts`
|
||||||
|
- `src/lib/server/pipeline/progress-broadcaster.ts`
|
||||||
|
- `src/lib/server/pipeline/progress-broadcaster.test.ts`
|
||||||
|
- `src/lib/server/pipeline/worker-pool.ts`
|
||||||
|
- `src/lib/server/pipeline/worker-entry.ts`
|
||||||
|
- `src/lib/server/pipeline/embed-worker-entry.ts`
|
||||||
|
- `src/lib/server/pipeline/worker-types.ts`
|
||||||
|
- `src/lib/server/pipeline/startup.ts`
|
||||||
|
- `src/lib/server/pipeline/indexing.pipeline.ts`
|
||||||
|
- `src/lib/server/pipeline/indexing.pipeline.test.ts`
|
||||||
|
- `src/routes/api/v1/jobs/+server.ts`
|
||||||
|
- `src/routes/api/v1/jobs/stream/+server.ts`
|
||||||
|
- `src/routes/api/v1/jobs/[id]/stream/+server.ts`
|
||||||
|
- `src/routes/api/v1/sse-and-settings.integration.test.ts`
|
||||||
|
- `src/routes/admin/jobs/+page.svelte`
|
||||||
|
- `src/lib/components/IndexingProgress.svelte`
|
||||||
|
- `src/lib/components/admin/JobStatusBadge.svelte`
|
||||||
|
- `src/lib/components/admin/JobSkeleton.svelte`
|
||||||
|
- `src/lib/components/admin/Toast.svelte`
|
||||||
|
- `src/lib/components/admin/WorkerStatusPanel.svelte`
|
||||||
|
- `scripts/build-workers.mjs`
|
||||||
|
- `node_modules/libsql/types/index.d.ts`
|
||||||
|
- `node_modules/libsql/index.js`
|
||||||
|
- Findings:
|
||||||
|
- Iteration 0 already changed the workspace materially: direct DB imports were switched from `better-sqlite3` to `libsql`, the extra WAL-related pragmas were added in the main DB clients and embed worker, composite indexes plus `vec_embedding` were added to the Drizzle schema and migration metadata, `IndexingProgress.svelte` now uses SSE, the admin jobs page was overhauled, and `WorkerPool` now serializes on `(repositoryId, versionId)` instead of repository only.
|
||||||
|
- The rejected vector implementation is still invalid in the current tree. `src/lib/server/db/vectors.sql` contains the rejected libSQL-native assumptions, including a dangling `USING libsql_vector_idx(...)` clause with no valid `CREATE INDEX` statement, and `src/lib/server/search/vector.search.ts` still performs full-table JS cosine scoring over `snippet_embeddings` instead of true in-database KNN.
|
||||||
|
- `sqlite-vec` is not currently present in `package.json` or the lockfile, and there is no existing `sqliteVec.load(...)`, `db.loadExtension(...)`, `vec0`, or extension bootstrap code anywhere under `src/`.
|
||||||
|
- Context7 sqlite-vec docs confirm the supported Node integration path is `import * as sqliteVec from 'sqlite-vec'; sqliteVec.load(db);`, storing vectors in a `vec0` virtual table and querying with `WHERE embedding MATCH ? ORDER BY distance LIMIT ?`. The docs also show vec0 metadata columns can be filtered directly, which fits the repositoryId, versionId, and profileId requirements.
|
||||||
|
- Context7 `better-sqlite3` v12.6.2 docs confirm `db.loadExtension(path)` exists. The installed `libsql` package in this workspace also exposes `loadExtension(path): this` in `node_modules/libsql/types/index.d.ts` and `loadExtension(...args)` in `node_modules/libsql/index.js`, so extension loading is not obviously blocked by the driver API surface alone.
|
||||||
|
- The review report remains the only verified runtime evidence for the current libsql path: `vector_from_float32(...)` is unavailable and `libsql_vector_idx` DDL is rejected in this environment. That invalidates the original native-vector approach but does not by itself prove sqlite-vec extension loading succeeds through the current `libsql` package alias, so the replan must include explicit connection-bootstrap and test coverage for real extension loading on the main DB client and worker-owned connections.
|
||||||
|
- Two iteration-0 deliverables referenced in the rejected plan do not exist in the current worktree: `src/lib/server/pipeline/write-worker-entry.ts` and `src/routes/api/v1/workers/+server.ts`. `scripts/build-workers.mjs` and the admin `WorkerStatusPanel.svelte` already reference those missing paths, so iteration 1 must either create them or revert those dangling references as part of a consistent plan.
|
||||||
|
- The existing admin/SSE work is largely salvageable. `src/routes/api/v1/jobs/stream/+server.ts`, `src/routes/api/v1/jobs/[id]/stream/+server.ts`, `src/lib/server/pipeline/progress-broadcaster.ts`, `src/lib/components/IndexingProgress.svelte`, `src/lib/components/admin/JobSkeleton.svelte`, `src/lib/components/admin/Toast.svelte`, and `src/lib/components/admin/WorkerStatusPanel.svelte` provide a usable foundation, but `src/routes/admin/jobs/+page.svelte` still contains `confirm(...)` and the queue API still only supports exact `repository_id = ?` and single-status filtering.
|
||||||
|
- The existing tests still encode the rejected pre-sqlite-vec model: `embedding.service.test.ts`, `schema.test.ts`, `hybrid.search.service.test.ts`, and `indexing.pipeline.test.ts` seed and assert against `snippet_embeddings.embedding` blobs only. The sqlite-vec replan therefore needs new DB bootstrap helpers, vec-table lifecycle assertions, and vector-search tests that validate actual vec0 writes and filtered KNN queries.
|
||||||
|
- Risks / follow-ups:
|
||||||
|
- The current worktree is dirty with iteration-0 partial changes and generated migration metadata, so iteration-1 tasks must explicitly distinguish keep/revise/revert work to avoid sibling tasks fighting over the same files.
|
||||||
|
- Because the current `libsql` package appears to expose `loadExtension`, the replan should avoid assuming an immediate full revert to upstream `better-sqlite3`; instead it should sequence a driver/bootstrap compatibility decision around actual sqlite-vec extension loading behavior with testable acceptance criteria.
|
||||||
|
|
||||||
|
### 2026-04-01 — TRUEREF-0023 iteration-2 current-worktree verification
|
||||||
|
|
||||||
|
- Task: Replan iteration 2 against the post-iteration-1 workspace state so the first validation unit no longer leaves a known vec_embedding mismatch behind.
|
||||||
|
- Files inspected:
|
||||||
|
- `package.json`
|
||||||
|
- `package-lock.json`
|
||||||
|
- `scripts/build-workers.mjs`
|
||||||
|
- `src/lib/server/db/client.ts`
|
||||||
|
- `src/lib/server/db/index.ts`
|
||||||
|
- `src/lib/server/db/schema.ts`
|
||||||
|
- `src/lib/server/db/vectors.sql`
|
||||||
|
- `src/lib/server/db/migrations/0006_yielding_centennial.sql`
|
||||||
|
- `src/lib/server/db/schema.test.ts`
|
||||||
|
- `src/lib/server/embeddings/embedding.service.ts`
|
||||||
|
- `src/lib/server/embeddings/embedding.service.test.ts`
|
||||||
|
- `src/lib/server/search/vector.search.ts`
|
||||||
|
- `src/lib/server/search/hybrid.search.service.ts`
|
||||||
|
- `src/lib/server/search/hybrid.search.service.test.ts`
|
||||||
|
- `src/lib/server/pipeline/job-queue.ts`
|
||||||
|
- `src/lib/server/pipeline/worker-pool.ts`
|
||||||
|
- `src/lib/server/pipeline/worker-entry.ts`
|
||||||
|
- `src/lib/server/pipeline/embed-worker-entry.ts`
|
||||||
|
- `src/lib/server/pipeline/worker-types.ts`
|
||||||
|
- `src/lib/server/pipeline/indexing.pipeline.ts`
|
||||||
|
- `src/lib/server/pipeline/indexing.pipeline.test.ts`
|
||||||
|
- `src/lib/server/pipeline/startup.ts`
|
||||||
|
- `src/lib/server/pipeline/progress-broadcaster.ts`
|
||||||
|
- `src/routes/api/v1/jobs/+server.ts`
|
||||||
|
- `src/routes/api/v1/jobs/stream/+server.ts`
|
||||||
|
- `src/routes/api/v1/sse-and-settings.integration.test.ts`
|
||||||
|
- `src/routes/admin/jobs/+page.svelte`
|
||||||
|
- `src/lib/components/IndexingProgress.svelte`
|
||||||
|
- `src/lib/components/admin/JobStatusBadge.svelte`
|
||||||
|
- `src/lib/components/admin/JobSkeleton.svelte`
|
||||||
|
- `src/lib/components/admin/Toast.svelte`
|
||||||
|
- `src/lib/components/admin/WorkerStatusPanel.svelte`
|
||||||
|
- `prompts/TRUEREF-0023/iteration_1/plan.md`
|
||||||
|
- `prompts/TRUEREF-0023/iteration_1/tasks.yaml`
|
||||||
|
- Findings:
|
||||||
|
- Iteration 1 already completed the direct-driver reset in the working tree: `package.json` and `package-lock.json` now contain real `better-sqlite3` plus `sqlite-vec`, and the current production/test files read in this pass import `better-sqlite3`, not `libsql`.
|
||||||
|
- The remaining failing intermediate state is exactly the schema/write mismatch called out in the review report: `src/lib/server/db/schema.ts` and `src/lib/server/db/migrations/0006_yielding_centennial.sql` still declare `vec_embedding`, `src/lib/server/db/index.ts` still executes `vectors.sql`, and `src/lib/server/embeddings/embedding.service.ts` still inserts `(embedding, vec_embedding)` into `snippet_embeddings`.
|
||||||
|
- `src/lib/server/db/vectors.sql` is still invalid startup SQL. It contains a dangling `USING libsql_vector_idx(...)` clause with no enclosing `CREATE INDEX`, so leaving it in the initialization path keeps the rejected libSQL-native design alive.
|
||||||
|
- The first iteration-1 task boundary was therefore wrong for the current baseline: the package/import reset is already present, but it only becomes a valid foundation once the relational `vec_embedding` artifacts and `EmbeddingService` insert path are cleaned up in the same validation unit.
|
||||||
|
- The current search path is still the pre-sqlite-vec implementation. `src/lib/server/search/vector.search.ts` reads every candidate embedding blob and scores in JavaScript; no `vec0`, `sqliteVec.load(db)`, or sqlite-vec KNN query exists anywhere under `src/` yet.
|
||||||
|
- The write worker and worker-status backend are still missing in the live tree even though they are already referenced elsewhere: `scripts/build-workers.mjs` includes `src/lib/server/pipeline/write-worker-entry.ts`, `src/lib/components/admin/WorkerStatusPanel.svelte` fetches `/api/v1/workers`, and `src/routes/api/v1/jobs/stream/+server.ts` currently has no worker-status event source.
|
||||||
|
- The admin jobs page remains incomplete but salvageable: `src/routes/admin/jobs/+page.svelte` still uses `confirm(...)` and `alert(...)`, while `JobSkeleton.svelte`, `Toast.svelte`, `WorkerStatusPanel.svelte`, `JobStatusBadge.svelte`, and `IndexingProgress.svelte` already provide the intended UI foundation.
|
||||||
|
- `src/lib/server/pipeline/job-queue.ts` still only supports exact `repository_id = ?` and single `status = ?` filtering, so API-side filter work remains a separate backend task and does not need to block the vector-storage implementation.
|
||||||
|
- Risks / follow-ups:
|
||||||
|
- Iteration 2 task decomposition must treat the current dirty code files from iterations 0 and 1 as the validation baseline, otherwise the executor will keep rediscovering pre-existing worktree drift instead of new task deltas.
|
||||||
|
- The sqlite-vec bootstrap helper and the relational cleanup should be planned as one acceptance unit before any downstream vec0, worker-status, or admin-page tasks, because that is the smallest unit that removes the known broken intermediate state.
|
||||||
|
|
||||||
|
### 2026-04-01T00:00:00.000Z — TRUEREF-0023 iteration 3 navbar follow-up planning research
|
||||||
|
|
||||||
|
- Task: Plan the accepted follow-up request to add an admin route to the main navbar.
|
||||||
|
- Files inspected:
|
||||||
|
- `prompts/TRUEREF-0023/progress.yaml`
|
||||||
|
- `prompts/TRUEREF-0023/iteration_2/review_report.yaml`
|
||||||
|
- `prompts/TRUEREF-0023/prompt.yaml`
|
||||||
|
- `package.json`
|
||||||
|
- `src/routes/+layout.svelte`
|
||||||
|
- `src/routes/admin/jobs/+page.svelte`
|
||||||
|
- Findings:
|
||||||
|
- The accepted iteration-2 workspace is green: `review_report.yaml` records passing build, passing tests, and no workspace diagnostics, so this request is a narrow additive follow-up rather than a rework of the sqlite-vec/admin jobs implementation.
|
||||||
|
- The main navbar is defined entirely in `src/routes/+layout.svelte` and already uses base-aware SvelteKit navigation via `resolve as resolveRoute` from `$app/paths` for the existing `Repositories`, `Search`, and `Settings` links.
|
||||||
|
- The existing admin surface already lives at `src/routes/admin/jobs/+page.svelte`, which sets the page title to `Job Queue - TrueRef Admin`; adding a navbar entry can therefore target `/admin/jobs` directly without introducing new routes, loaders, or components.
|
||||||
|
- Repository findings from the earlier lint planning work already confirm the codebase expectation to avoid root-relative internal navigation in SvelteKit pages and components, so the new navbar link should follow the existing `resolveRoute('/...')` anchor pattern.
|
||||||
|
- No dedicated test file currently covers the shared navbar. The appropriate validation for this follow-up remains repository-level `npm run build` and `npm test` after the single layout edit.
|
||||||
|
- Risks / follow-ups:
|
||||||
|
- The follow-up navigation request should stay isolated to the shared layout so it does not reopen the accepted sqlite-vec implementation surface.
|
||||||
|
- Build and test validation remain the appropriate regression checks because no dedicated navbar test currently exists.
|
||||||
|
|
||||||
|
### 2026-04-01T12:05:23.000Z — TRUEREF-0023 iteration 5 tabs filter and bulk reprocess planning research
|
||||||
|
|
||||||
|
- Task: Plan the follow-up repo-detail UI change to filter version rows in the tabs/tags view and add a bulk action that reprocesses all errored tags without adding a new backend endpoint.
|
||||||
|
- Files inspected:
|
||||||
|
- `prompts/TRUEREF-0023/progress.yaml`
|
||||||
|
- `prompts/TRUEREF-0023/prompt.yaml`
|
||||||
|
- `prompts/TRUEREF-0023/iteration_2/plan.md`
|
||||||
|
- `prompts/TRUEREF-0023/iteration_2/tasks.yaml`
|
||||||
|
- `src/routes/repos/[id]/+page.svelte`
|
||||||
|
- `src/routes/api/v1/libs/[id]/versions/[tag]/index/+server.ts`
|
||||||
|
- `src/routes/api/v1/api-contract.integration.test.ts`
|
||||||
|
- `package.json`
|
||||||
|
- Findings:
|
||||||
|
- The relevant UI surface is entirely in `src/routes/repos/[id]/+page.svelte`; the page already loads `versions`, renders per-version state badges, and exposes per-tag `Index` and `Remove` buttons.
|
||||||
|
- Version states are concretely `pending`, `indexing`, `indexed`, and `error`, and the page already centralizes their labels and color classes in `stateLabels` and `stateColors`.
|
||||||
|
- Existing per-tag reprocessing is implemented by `handleIndexVersion(tag)`, which POSTs to `/api/v1/libs/:id/versions/:tag/index`; the corresponding backend route exists and returns a queued job DTO with status `202`.
|
||||||
|
- No bulk reprocess endpoint exists, so the lowest-risk implementation is a UI-only bulk action that iterates the existing per-tag route.
|
||||||
|
- The page already contains a bounded batching pattern in `handleRegisterSelected()` with `BATCH_SIZE = 5`, which provides a concrete local precedent for bulk tag operations without inventing a new concurrency model.
|
||||||
|
- There is no existing page-component or browser test targeting `src/routes/repos/[id]/+page.svelte`; nearby automated coverage is API-contract focused, so this iteration should rely on `npm run build` and `npm test` regression validation unless a developer discovers an existing Svelte page harness during implementation.
|
||||||
|
- Context7 lookup for Svelte and SvelteKit could not be completed in this environment because the configured API key is invalid; planning therefore relied on installed versions from `package.json` (`svelte` `^5.51.0`, `@sveltejs/kit` `^2.50.2`) and the live page patterns already present in the repository.
|
||||||
|
- Risks / follow-ups:
|
||||||
|
- Bulk reprocessing must avoid queuing duplicate jobs for tags already shown as `indexing` or already tracked in `activeVersionJobs`.
|
||||||
|
- Filter state should be implemented as local UI state only and must not disturb the existing `onMount(loadVersions)` fetch path or the SSE job-progress flow.
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ Executed in `IndexingPipeline.run()` before the crawl, when the job has a `versi
|
|||||||
containing shell metacharacters).
|
containing shell metacharacters).
|
||||||
|
|
||||||
3. **Path partitioning**: The changed-file list is split into `changedPaths` (added + modified
|
3. **Path partitioning**: The changed-file list is split into `changedPaths` (added + modified
|
||||||
+ renamed-destination) and `deletedPaths`. `unchangedPaths` is derived as
|
- renamed-destination) and `deletedPaths`. `unchangedPaths` is derived as
|
||||||
`ancestorFilePaths − changedPaths − deletedPaths`.
|
`ancestorFilePaths − changedPaths − deletedPaths`.
|
||||||
|
|
||||||
4. **Guard**: Returns `null` when no indexed ancestor exists, when the ancestor has no indexed
|
4. **Guard**: Returns `null` when no indexed ancestor exists, when the ancestor has no indexed
|
||||||
documents, or when all files changed (nothing to clone).
|
documents, or when all files changed (nothing to clone).
|
||||||
@@ -74,18 +74,18 @@ matching files are returned. This minimises GitHub API requests and local I/O.
|
|||||||
|
|
||||||
## API Surface Changes
|
## API Surface Changes
|
||||||
|
|
||||||
| Symbol | Location | Change |
|
| Symbol | Location | Change |
|
||||||
|---|---|---|
|
| -------------------------------------- | ----------------------------------- | --------------------------------------------- |
|
||||||
| `buildDifferentialPlan` | `pipeline/differential-strategy.ts` | **New** — async function |
|
| `buildDifferentialPlan` | `pipeline/differential-strategy.ts` | **New** — async function |
|
||||||
| `DifferentialPlan` | `pipeline/differential-strategy.ts` | **New** — interface |
|
| `DifferentialPlan` | `pipeline/differential-strategy.ts` | **New** — interface |
|
||||||
| `findBestAncestorVersion` | `utils/tag-order.ts` | **New** — pure function |
|
| `findBestAncestorVersion` | `utils/tag-order.ts` | **New** — pure function |
|
||||||
| `fetchGitHubChangedFiles` | `crawler/github-compare.ts` | **New** — async function |
|
| `fetchGitHubChangedFiles` | `crawler/github-compare.ts` | **New** — async function |
|
||||||
| `getChangedFilesBetweenRefs` | `utils/git.ts` | **New** — sync function (uses `execFileSync`) |
|
| `getChangedFilesBetweenRefs` | `utils/git.ts` | **New** — sync function (uses `execFileSync`) |
|
||||||
| `ChangedFile` | `crawler/types.ts` | **New** — interface |
|
| `ChangedFile` | `crawler/types.ts` | **New** — interface |
|
||||||
| `CrawlOptions.allowedPaths` | `crawler/types.ts` | **New** — optional field |
|
| `CrawlOptions.allowedPaths` | `crawler/types.ts` | **New** — optional field |
|
||||||
| `IndexingPipeline.crawl()` | `pipeline/indexing.pipeline.ts` | **Modified** — added `allowedPaths` param |
|
| `IndexingPipeline.crawl()` | `pipeline/indexing.pipeline.ts` | **Modified** — added `allowedPaths` param |
|
||||||
| `IndexingPipeline.cloneFromAncestor()` | `pipeline/indexing.pipeline.ts` | **New** — private method |
|
| `IndexingPipeline.cloneFromAncestor()` | `pipeline/indexing.pipeline.ts` | **New** — private method |
|
||||||
| `IndexingPipeline.run()` | `pipeline/indexing.pipeline.ts` | **Modified** — Stage 0 added |
|
| `IndexingPipeline.run()` | `pipeline/indexing.pipeline.ts` | **Modified** — Stage 0 added |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ The UI currently polls `GET /api/v1/jobs?repositoryId=...` every 2 seconds. This
|
|||||||
#### Worker Thread lifecycle
|
#### Worker Thread lifecycle
|
||||||
|
|
||||||
Each worker is a long-lived `node:worker_threads` `Worker` instance that:
|
Each worker is a long-lived `node:worker_threads` `Worker` instance that:
|
||||||
|
|
||||||
1. Opens its own `better-sqlite3` connection to the same database file.
|
1. Opens its own `better-sqlite3` connection to the same database file.
|
||||||
2. Listens for `{ type: 'run', jobId }` messages from the main thread.
|
2. Listens for `{ type: 'run', jobId }` messages from the main thread.
|
||||||
3. Runs `IndexingPipeline.run(job)`, emitting `postMessage` progress events at each stage boundary and every N files.
|
3. Runs `IndexingPipeline.run(job)`, emitting `postMessage` progress events at each stage boundary and every N files.
|
||||||
@@ -100,18 +101,18 @@ Manages a pool of `concurrency` workers.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface WorkerPoolOptions {
|
interface WorkerPoolOptions {
|
||||||
concurrency: number; // default: Math.max(1, os.cpus().length - 1), capped at 4
|
concurrency: number; // default: Math.max(1, os.cpus().length - 1), capped at 4
|
||||||
workerScript: string; // absolute path to the compiled worker entry
|
workerScript: string; // absolute path to the compiled worker entry
|
||||||
}
|
}
|
||||||
|
|
||||||
class WorkerPool {
|
class WorkerPool {
|
||||||
private workers: Worker[];
|
private workers: Worker[];
|
||||||
private idle: Worker[];
|
private idle: Worker[];
|
||||||
|
|
||||||
enqueue(jobId: string): void;
|
enqueue(jobId: string): void;
|
||||||
private dispatch(worker: Worker, jobId: string): void;
|
private dispatch(worker: Worker, jobId: string): void;
|
||||||
private onWorkerMessage(msg: WorkerMessage): void;
|
private onWorkerMessage(msg: WorkerMessage): void;
|
||||||
private onWorkerExit(worker: Worker, code: number): void;
|
private onWorkerExit(worker: Worker, code: number): void;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -120,12 +121,14 @@ Workers are kept alive across jobs. If a worker crashes (non-zero exit), the poo
|
|||||||
#### Parallelism and write contention
|
#### Parallelism and write contention
|
||||||
|
|
||||||
With WAL mode enabled (already the case), SQLite supports:
|
With WAL mode enabled (already the case), SQLite supports:
|
||||||
|
|
||||||
- **One concurrent writer** (the transaction lock)
|
- **One concurrent writer** (the transaction lock)
|
||||||
- **Many concurrent readers**
|
- **Many concurrent readers**
|
||||||
|
|
||||||
The `replaceSnippets` transaction for different repositories never contends — they write different rows. The `cloneFromAncestor` operation writes to the same tables but different `version_id` values, so WAL checkpoint logic keeps them non-overlapping at the page level.
|
The `replaceSnippets` transaction for different repositories never contends — they write different rows. The `cloneFromAncestor` operation writes to the same tables but different `version_id` values, so WAL checkpoint logic keeps them non-overlapping at the page level.
|
||||||
|
|
||||||
Two jobs on the **same repository** (e.g. `/my-lib/v1.0.0` and `/my-lib/v2.0.0`) can run in parallel because:
|
Two jobs on the **same repository** (e.g. `/my-lib/v1.0.0` and `/my-lib/v2.0.0`) can run in parallel because:
|
||||||
|
|
||||||
- Differential indexing (TRUEREF-0021) ensures `v2.0.0` reads from `v1.0.0`'s already-committed rows.
|
- Differential indexing (TRUEREF-0021) ensures `v2.0.0` reads from `v1.0.0`'s already-committed rows.
|
||||||
- The write transactions for each version touch disjoint `version_id` partitions.
|
- The write transactions for each version touch disjoint `version_id` partitions.
|
||||||
|
|
||||||
@@ -134,6 +137,7 @@ If write contention still occurs under parallel load, `busy_timeout = 5000` (alr
|
|||||||
#### Concurrency limit per repository
|
#### Concurrency limit per repository
|
||||||
|
|
||||||
To prevent a user from queuing 500 tags and overwhelming the worker pool, the pool enforces:
|
To prevent a user from queuing 500 tags and overwhelming the worker pool, the pool enforces:
|
||||||
|
|
||||||
- **Max 1 running job per repository** for the default branch (re-index).
|
- **Max 1 running job per repository** for the default branch (re-index).
|
||||||
- **Max `concurrency` total running jobs** across all repositories.
|
- **Max `concurrency` total running jobs** across all repositories.
|
||||||
- Version jobs for the same repository are serialised within the pool (the queue picks the oldest queued version job for a given repo only when no other version job for that repo is running).
|
- Version jobs for the same repository are serialised within the pool (the queue picks the oldest queued version job for a given repo only when no other version job for that repo is running).
|
||||||
@@ -148,15 +152,15 @@ Replace the opaque integer progress with a structured stage model:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type IndexingStage =
|
type IndexingStage =
|
||||||
| 'queued'
|
| 'queued'
|
||||||
| 'differential' // computing ancestor diff
|
| 'differential' // computing ancestor diff
|
||||||
| 'crawling' // fetching files from GitHub or local FS
|
| 'crawling' // fetching files from GitHub or local FS
|
||||||
| 'cloning' // cloning unchanged files from ancestor (differential only)
|
| 'cloning' // cloning unchanged files from ancestor (differential only)
|
||||||
| 'parsing' // parsing files into snippets
|
| 'parsing' // parsing files into snippets
|
||||||
| 'storing' // writing documents + snippets to DB
|
| 'storing' // writing documents + snippets to DB
|
||||||
| 'embedding' // generating vector embeddings
|
| 'embedding' // generating vector embeddings
|
||||||
| 'done'
|
| 'done'
|
||||||
| 'failed';
|
| 'failed';
|
||||||
```
|
```
|
||||||
|
|
||||||
### Extended Job Schema
|
### Extended Job Schema
|
||||||
@@ -172,22 +176,24 @@ The `progress` column (0–100) is retained for backward compatibility and overa
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ProgressMessage {
|
interface ProgressMessage {
|
||||||
type: 'progress';
|
type: 'progress';
|
||||||
jobId: string;
|
jobId: string;
|
||||||
stage: IndexingStage;
|
stage: IndexingStage;
|
||||||
stageDetail?: string; // human-readable detail for the current stage
|
stageDetail?: string; // human-readable detail for the current stage
|
||||||
progress: number; // 0–100 overall
|
progress: number; // 0–100 overall
|
||||||
processedFiles: number;
|
processedFiles: number;
|
||||||
totalFiles: number;
|
totalFiles: number;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Workers emit this message:
|
Workers emit this message:
|
||||||
|
|
||||||
- On every stage transition (crawl start, parse start, store start, embed start).
|
- On every stage transition (crawl start, parse start, store start, embed start).
|
||||||
- Every `PROGRESS_EMIT_EVERY = 10` files during the parse loop.
|
- Every `PROGRESS_EMIT_EVERY = 10` files during the parse loop.
|
||||||
- On job completion or failure.
|
- On job completion or failure.
|
||||||
|
|
||||||
The main thread receives these messages and does two things:
|
The main thread receives these messages and does two things:
|
||||||
|
|
||||||
1. Writes the update to `indexing_jobs` in SQLite (batched — one write per message, not per file).
|
1. Writes the update to `indexing_jobs` in SQLite (batched — one write per message, not per file).
|
||||||
2. Pushes the payload to any open SSE channels for that jobId.
|
2. Pushes the payload to any open SSE channels for that jobId.
|
||||||
|
|
||||||
@@ -198,6 +204,7 @@ The main thread receives these messages and does two things:
|
|||||||
### `GET /api/v1/jobs/:id/stream`
|
### `GET /api/v1/jobs/:id/stream`
|
||||||
|
|
||||||
Opens an SSE connection for a specific job. The server:
|
Opens an SSE connection for a specific job. The server:
|
||||||
|
|
||||||
1. Sends the current job state as the first event immediately (no initial lag).
|
1. Sends the current job state as the first event immediately (no initial lag).
|
||||||
2. Pushes `ProgressMessage` events as the worker emits them.
|
2. Pushes `ProgressMessage` events as the worker emits them.
|
||||||
3. Sends a final `event: done` or `event: failed` event, then closes the connection.
|
3. Sends a final `event: done` or `event: failed` event, then closes the connection.
|
||||||
@@ -281,7 +288,7 @@ Expose via the settings table (key `indexing.concurrency`):
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface IndexingSettings {
|
interface IndexingSettings {
|
||||||
concurrency: number; // 1–max(cpus-1, 1); default 2
|
concurrency: number; // 1–max(cpus-1, 1); default 2
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -362,13 +369,13 @@ The embedding stage must **not** run inside the same Worker Thread as the crawl/
|
|||||||
|
|
||||||
### Why a dedicated embedding worker
|
### Why a dedicated embedding worker
|
||||||
|
|
||||||
| Concern | Per-parse-worker model | Dedicated embedding worker |
|
| Concern | Per-parse-worker model | Dedicated embedding worker |
|
||||||
|---|---|---|
|
| ------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
||||||
| Memory | N × ~100 MB (model weights + WASM heap) per worker | 1 × ~100 MB regardless of concurrency |
|
| Memory | N × ~100 MB (model weights + WASM heap) per worker | 1 × ~100 MB regardless of concurrency |
|
||||||
| Model warm-up | Paid once per worker spawn; cold starts slow | Paid once at server startup |
|
| Model warm-up | Paid once per worker spawn; cold starts slow | Paid once at server startup |
|
||||||
| Batch size | Each worker batches only its own job's snippets | All in-flight jobs queue to one worker → larger batches → higher WASM throughput |
|
| Batch size | Each worker batches only its own job's snippets | All in-flight jobs queue to one worker → larger batches → higher WASM throughput |
|
||||||
| Provider migration | Must update every worker | Update one file |
|
| Provider migration | Must update every worker | Update one file |
|
||||||
| API rate limiting | N parallel streams to the same API → N×rate-limit hits | One serial stream, naturally throttled |
|
| API rate limiting | N parallel streams to the same API → N×rate-limit hits | One serial stream, naturally throttled |
|
||||||
|
|
||||||
With `Xenova/all-MiniLM-L6-v2`, the WASM model and weight files occupy ~90–120 MB of heap. Running three parse workers with embedded model loading costs ~300–360 MB of resident memory that can never be freed while the server is alive. A dedicated worker keeps that cost fixed at one instance.
|
With `Xenova/all-MiniLM-L6-v2`, the WASM model and weight files occupy ~90–120 MB of heap. Running three parse workers with embedded model loading costs ~300–360 MB of resident memory that can never be freed while the server is alive. A dedicated worker keeps that cost fixed at one instance.
|
||||||
|
|
||||||
@@ -415,6 +422,7 @@ Instead, the existing `findSnippetIdsMissingEmbeddings` query is the handshake:
|
|||||||
5. Main thread routes this to the SSE broadcaster → UI updates the embedding progress slice.
|
5. Main thread routes this to the SSE broadcaster → UI updates the embedding progress slice.
|
||||||
|
|
||||||
This means:
|
This means:
|
||||||
|
|
||||||
- The embedding worker reads snippet text from the DB itself (no IPC serialisation of content).
|
- The embedding worker reads snippet text from the DB itself (no IPC serialisation of content).
|
||||||
- The model is loaded once, stays warm, and processes batches from all repositories in FIFO order.
|
- The model is loaded once, stays warm, and processes batches from all repositories in FIFO order.
|
||||||
- Parse workers are never blocked waiting for embeddings — they complete their job stages and exit immediately.
|
- Parse workers are never blocked waiting for embeddings — they complete their job stages and exit immediately.
|
||||||
@@ -424,15 +432,15 @@ This means:
|
|||||||
```typescript
|
```typescript
|
||||||
// Main → Embedding worker
|
// Main → Embedding worker
|
||||||
type EmbedRequest =
|
type EmbedRequest =
|
||||||
| { type: 'embed'; jobId: string; repositoryId: string; versionId: string | null }
|
| { type: 'embed'; jobId: string; repositoryId: string; versionId: string | null }
|
||||||
| { type: 'shutdown' };
|
| { type: 'shutdown' };
|
||||||
|
|
||||||
// Embedding worker → Main
|
// Embedding worker → Main
|
||||||
type EmbedResponse =
|
type EmbedResponse =
|
||||||
| { type: 'embed-progress'; jobId: string; done: number; total: number }
|
| { type: 'embed-progress'; jobId: string; done: number; total: number }
|
||||||
| { type: 'embed-done'; jobId: string }
|
| { type: 'embed-done'; jobId: string }
|
||||||
| { type: 'embed-failed'; jobId: string; error: string }
|
| { type: 'embed-failed'; jobId: string; error: string }
|
||||||
| { type: 'ready' }; // emitted once after model warm-up completes
|
| { type: 'ready' }; // emitted once after model warm-up completes
|
||||||
```
|
```
|
||||||
|
|
||||||
The `ready` message allows the server startup sequence to defer routing any embed requests until the model is loaded, preventing a race on first-run.
|
The `ready` message allows the server startup sequence to defer routing any embed requests until the model is loaded, preventing a race on first-run.
|
||||||
|
|||||||
955
docs/features/TRUEREF-0023.md
Normal file
955
docs/features/TRUEREF-0023.md
Normal file
@@ -0,0 +1,955 @@
|
|||||||
|
# TRUEREF-0023 — libSQL Migration, Native Vector Search, Parallel Tag Indexing, and Performance Hardening
|
||||||
|
|
||||||
|
**Priority:** P1
|
||||||
|
**Status:** Draft
|
||||||
|
**Depends On:** TRUEREF-0001, TRUEREF-0022
|
||||||
|
**Blocks:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
TrueRef currently uses `better-sqlite3` for all database access. This creates three compounding performance problems:
|
||||||
|
|
||||||
|
1. **Vector search does not scale.** `VectorSearch.vectorSearch()` loads the entire `snippet_embeddings` table for a repository into Node.js memory and computes cosine similarity in a JavaScript loop. A repository with 100k snippets at 1536 OpenAI dimensions allocates ~600 MB per query and ties up the worker thread for seconds before returning results.
|
||||||
|
2. **Missing composite indexes cause table scans on every query.** The schema defines FK columns used in every search and embedding filter, but declares zero composite or covering indexes on them. Every call to `searchSnippets`, `findSnippetIdsMissingEmbeddings`, and `cloneFromAncestor` performs full or near-full table scans.
|
||||||
|
3. **SQLite connection is under-configured.** Critical pragmas (`synchronous`, `cache_size`, `mmap_size`, `temp_store`) are absent, leaving significant I/O throughput on the table.
|
||||||
|
|
||||||
|
The solution is to replace `better-sqlite3` with `@libsql/better-sqlite3` — an embeddable, drop-in synchronous replacement that is a superset of the better-sqlite3 API and exposes libSQL's native vector index (`libsql_vector_idx`). Because the API is identical, no service layer or ORM code changes are needed beyond import statements and the vector search implementation.
|
||||||
|
|
||||||
|
Two additional structural improvements are delivered in the same feature:
|
||||||
|
|
||||||
|
4. **Per-repo job serialization is too coarse.** `WorkerPool` prevents any two jobs sharing the same `repositoryId` from running in parallel. This means indexing 200 tags of a single library is fully sequential — one tag at a time — even though different tags write to entirely disjoint row sets. The constraint should track `(repositoryId, versionId)` pairs instead.
|
||||||
|
5. **Write lock contention under parallel indexing.** When multiple parse workers flush parsed snippets simultaneously they all compete for the SQLite write lock, spending most of their time in `busy_timeout` back-off. A single dedicated write worker eliminates this: parse workers become pure CPU workers (crawl → parse → send batches over `postMessage`) and the write worker is the sole DB writer.
|
||||||
|
6. **Admin UI is unusable under load.** The job queue page has no status or repository filters, no worker status panel, no skeleton loading, uses blocking `alert()` / `confirm()` dialogs, and `IndexingProgress` still polls every 2 seconds instead of consuming the existing SSE stream.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Replace `better-sqlite3` with `@libsql/better-sqlite3` with minimal code churn — import paths only.
|
||||||
|
2. Add a libSQL vector index on `snippet_embeddings` so that KNN queries execute inside SQLite instead of in a JavaScript loop.
|
||||||
|
3. Add the six composite and covering indexes required by the hot query paths.
|
||||||
|
4. Tune the SQLite pragma configuration for I/O performance.
|
||||||
|
5. Eliminate the leading cause of OOM risk during semantic search.
|
||||||
|
6. Keep a single embedded database file — no external server, no network.
|
||||||
|
7. Allow multiple tags of the same repository to index in parallel (unrelated version rows, no write conflict).
|
||||||
|
8. Eliminate write-lock contention between parallel parse workers by introducing a single dedicated write worker.
|
||||||
|
9. Rebuild the admin jobs page with full filtering (status, repository, free-text), a live worker status panel, skeleton loading on initial fetch, per-action inline spinners, non-blocking toast notifications, and SSE-driven real-time updates throughout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Migrating to the async `@libsql/client` package (HTTP/embedded-replica mode).
|
||||||
|
- Changing the Drizzle ORM adapter (`drizzle-orm/better-sqlite3` stays unchanged).
|
||||||
|
- Changing `drizzle.config.ts` dialect (`sqlite` is still correct for embedded libSQL).
|
||||||
|
- Adding hybrid/approximate indexing beyond the default HNSW strategy provided by `libsql_vector_idx`.
|
||||||
|
- Parallelizing embedding batches across providers (separate feature).
|
||||||
|
- Horizontally scaling across processes.
|
||||||
|
- Allowing more than one job for the exact same `(repositoryId, versionId)` pair to run concurrently (still serialized — duplicate detection in `JobQueue` is unchanged).
|
||||||
|
- A full admin authentication system (out of scope).
|
||||||
|
- Mobile-responsive redesign of the entire admin section (out of scope).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Detail
|
||||||
|
|
||||||
|
### 1. Vector Search — Full Table Scan in JavaScript
|
||||||
|
|
||||||
|
**File:** `src/lib/server/search/vector.search.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Current: no LIMIT, loads ALL embeddings for repo into memory
|
||||||
|
const rows = this.db.prepare<unknown[], RawEmbeddingRow>(sql).all(...params);
|
||||||
|
|
||||||
|
const scored: VectorSearchResult[] = rows.map((row) => {
|
||||||
|
const embedding = new Float32Array(
|
||||||
|
row.embedding.buffer,
|
||||||
|
row.embedding.byteOffset,
|
||||||
|
row.embedding.byteLength / 4
|
||||||
|
);
|
||||||
|
return { snippetId: row.snippet_id, score: cosineSimilarity(queryEmbedding, embedding) };
|
||||||
|
});
|
||||||
|
|
||||||
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
||||||
|
```
|
||||||
|
|
||||||
|
For a repo with N snippets and D dimensions, this allocates `N × D × 4` bytes per query. At N=100k and D=1536, that is ~600 MB allocated synchronously. The result is sorted entirely in JS before the top-k is returned. With a native vector index, SQLite returns only the top-k rows.
|
||||||
|
|
||||||
|
### 2. Missing Composite Indexes
|
||||||
|
|
||||||
|
The `snippets`, `documents`, and `snippet_embeddings` tables are queried with multi-column WHERE predicates in every hot path, but no composite indexes exist:
|
||||||
|
|
||||||
|
| Table | Filter columns | Used in |
|
||||||
|
| -------------------- | ----------------------------- | ---------------------------------------------- |
|
||||||
|
| `snippets` | `(repository_id, version_id)` | All search, diff, clone |
|
||||||
|
| `snippets` | `(repository_id, type)` | Type-filtered queries |
|
||||||
|
| `documents` | `(repository_id, version_id)` | Diff strategy, clone |
|
||||||
|
| `snippet_embeddings` | `(profile_id, snippet_id)` | `findSnippetIdsMissingEmbeddings` LEFT JOIN |
|
||||||
|
| `repositories` | `(state)` | `searchRepositories` WHERE `state = 'indexed'` |
|
||||||
|
| `indexing_jobs` | `(repository_id, status)` | Job status lookups |
|
||||||
|
|
||||||
|
Without these indexes, SQLite performs a B-tree scan of the primary key and filters rows in memory. On a 500k-row `snippets` table this is the dominant cost of every search.
|
||||||
|
|
||||||
|
### 4. Admin UI — Current Problems
|
||||||
|
|
||||||
|
**File:** `src/routes/admin/jobs/+page.svelte`, `src/lib/components/IndexingProgress.svelte`
|
||||||
|
|
||||||
|
| Problem | Location | Impact |
|
||||||
|
| -------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------ |
|
||||||
|
| `IndexingProgress` polls every 2 s via `setInterval` + `fetch` | `IndexingProgress.svelte` | Constant HTTP traffic; progress lags by up to 2 s |
|
||||||
|
| No status or repository filter controls | `admin/jobs/+page.svelte` | With 200 tag jobs, finding a specific one requires scrolling |
|
||||||
|
| No worker status panel | — (no endpoint exists) | Operator cannot see which workers are busy or idle |
|
||||||
|
| `alert()` for errors, `confirm()` for cancel | `admin/jobs/+page.svelte` — `showToast()` | Blocks the entire browser tab; unusable under parallel jobs |
|
||||||
|
| `actionInProgress` is a single string, not per-job | `admin/jobs/+page.svelte` | Pausing job A disables buttons on all other jobs |
|
||||||
|
| No skeleton loading — blank + spinner on first load | `admin/jobs/+page.svelte` | Layout shift; no structural preview while data loads |
|
||||||
|
| Hard-coded `limit=50` query, no pagination | `admin/jobs/+page.svelte:fetchJobs()` | Page truncates silently for large queues |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Under-configured SQLite Connection
|
||||||
|
|
||||||
|
**File:** `src/lib/server/db/client.ts` and `src/lib/server/db/index.ts`
|
||||||
|
|
||||||
|
Current pragmas:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
client.pragma('journal_mode = WAL');
|
||||||
|
client.pragma('foreign_keys = ON');
|
||||||
|
client.pragma('busy_timeout = 5000');
|
||||||
|
```
|
||||||
|
|
||||||
|
Missing:
|
||||||
|
|
||||||
|
- `synchronous = NORMAL` — halves fsync overhead vs the default FULL; safe with WAL
|
||||||
|
- `cache_size = -65536` — 64 MB page cache; default is 2 MB
|
||||||
|
- `temp_store = MEMORY` — temp tables and sort spills stay in RAM
|
||||||
|
- `mmap_size = 268435456` — 256 MB memory-mapped read path; bypasses system call overhead for reads
|
||||||
|
- `wal_autocheckpoint = 1000` — more frequent checkpoints prevent WAL growth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Drop-In Replacement: `@libsql/better-sqlite3`
|
||||||
|
|
||||||
|
`@libsql/better-sqlite3` is published by Turso and implemented as a Node.js native addon wrapping the libSQL embedded engine. The exported class is API-compatible with `better-sqlite3`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// before
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
const db = new Database('/path/to/file.db');
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
const rows = db.prepare('SELECT ...').all(...params);
|
||||||
|
|
||||||
|
// after — identical code
|
||||||
|
import Database from '@libsql/better-sqlite3';
|
||||||
|
const db = new Database('/path/to/file.db');
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
const rows = db.prepare('SELECT ...').all(...params);
|
||||||
|
```
|
||||||
|
|
||||||
|
All of the following continue to work unchanged:
|
||||||
|
|
||||||
|
- `drizzle-orm/better-sqlite3` adapter and `migrate` helper
|
||||||
|
- `drizzle-kit` with `dialect: 'sqlite'`
|
||||||
|
- Prepared statements, transactions, WAL pragmas, foreign keys
|
||||||
|
- Worker thread per-thread connections (`worker-entry.ts`, `embed-worker-entry.ts`)
|
||||||
|
- All `type Database from 'better-sqlite3'` type imports (replaced in lock-step)
|
||||||
|
|
||||||
|
### Vector Index Design
|
||||||
|
|
||||||
|
libSQL provides `libsql_vector_idx()` — a virtual index type stored in a shadow table alongside the main table. Once indexed, KNN queries use a SQL `vector_top_k()` function:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- KNN: return top-k snippet IDs closest to the query vector
|
||||||
|
SELECT snippet_id
|
||||||
|
FROM vector_top_k('idx_snippet_embeddings_vec', vector_from_float32(?), ?)
|
||||||
|
```
|
||||||
|
|
||||||
|
`vector_from_float32(blob)` accepts the same raw little-endian Float32 bytes currently stored in the `embedding` blob column. **No data migration is needed** — the existing blob column can be re-indexed with `libsql_vector_idx` pointing at the bytes-stored column.
|
||||||
|
|
||||||
|
The index strategy:
|
||||||
|
|
||||||
|
1. Add a generated `vec_embedding` column of type `F32_BLOB(dimensions)` to `snippet_embeddings`, populated from the existing `embedding` blob via a migration trigger.
|
||||||
|
2. Create the vector index: `CREATE INDEX idx_snippet_embeddings_vec ON snippet_embeddings(vec_embedding) USING libsql_vector_idx(vec_embedding)`.
|
||||||
|
3. Rewrite `VectorSearch.vectorSearch()` to use `vector_top_k()` with a two-step join instead of the in-memory loop.
|
||||||
|
4. Update `EmbeddingService.embedSnippets()` to write `vec_embedding` on insert.
|
||||||
|
|
||||||
|
Dimensions are profile-specific. Because the index is per-column, a separate index is needed per embedding dimensionality. For v1, a single index covering the default profile's dimensions is sufficient; multi-profile KNN can be handled with a `WHERE profile_id = ?` pre-filter on the vector_top_k results.
|
||||||
|
|
||||||
|
### Updated Vector Search Query
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
vectorSearch(queryEmbedding: Float32Array, options: VectorSearchOptions): VectorSearchResult[] {
|
||||||
|
const { repositoryId, versionId, profileId = 'local-default', limit = 50 } = options;
|
||||||
|
|
||||||
|
// Encode query vector as raw bytes (same format as stored blobs)
|
||||||
|
const queryBytes = Buffer.from(queryEmbedding.buffer);
|
||||||
|
|
||||||
|
// Use libSQL vector_top_k for ANN — returns ordered (rowid, distance) pairs
|
||||||
|
let sql = `
|
||||||
|
SELECT se.snippet_id,
|
||||||
|
vector_distance_cos(se.vec_embedding, vector_from_float32(?)) AS score
|
||||||
|
FROM vector_top_k('idx_snippet_embeddings_vec', vector_from_float32(?), ?) AS knn
|
||||||
|
JOIN snippet_embeddings se ON se.rowid = knn.id
|
||||||
|
JOIN snippets s ON s.id = se.snippet_id
|
||||||
|
WHERE s.repository_id = ?
|
||||||
|
AND se.profile_id = ?
|
||||||
|
`;
|
||||||
|
const params: unknown[] = [queryBytes, queryBytes, limit * 4, repositoryId, profileId];
|
||||||
|
|
||||||
|
if (versionId) {
|
||||||
|
sql += ' AND s.version_id = ?';
|
||||||
|
params.push(versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY score ASC LIMIT ?';
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
return this.db
|
||||||
|
.prepare<unknown[], { snippet_id: string; score: number }>(sql)
|
||||||
|
.all(...params)
|
||||||
|
.map((row) => ({ snippetId: row.snippet_id, score: 1 - row.score }));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`vector_distance_cos` returns distance (0 = identical), so `1 - distance` gives a similarity score in [0, 1] matching the existing `VectorSearchResult.score` contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1 — Package Swap (no logic changes)
|
||||||
|
|
||||||
|
**Files touched:** `package.json`, all `.ts` files that import `better-sqlite3`
|
||||||
|
|
||||||
|
1. In `package.json`:
|
||||||
|
- Remove `"better-sqlite3": "^12.6.2"` from `dependencies`
|
||||||
|
- Add `"@libsql/better-sqlite3": "^0.4.0"` to `dependencies`
|
||||||
|
- Remove `"@types/better-sqlite3": "^7.6.13"` from `devDependencies`
|
||||||
|
- `@libsql/better-sqlite3` ships its own TypeScript declarations
|
||||||
|
|
||||||
|
2. Replace all import statements (35 occurrences across 19 files):
|
||||||
|
|
||||||
|
| Old import | New import |
|
||||||
|
| --------------------------------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| `import Database from 'better-sqlite3'` | `import Database from '@libsql/better-sqlite3'` |
|
||||||
|
| `import type Database from 'better-sqlite3'` | `import type Database from '@libsql/better-sqlite3'` |
|
||||||
|
| `import { drizzle } from 'drizzle-orm/better-sqlite3'` | unchanged |
|
||||||
|
| `import { migrate } from 'drizzle-orm/better-sqlite3/migrator'` | unchanged |
|
||||||
|
|
||||||
|
Affected production files:
|
||||||
|
- `src/lib/server/db/index.ts`
|
||||||
|
- `src/lib/server/db/client.ts`
|
||||||
|
- `src/lib/server/embeddings/embedding.service.ts`
|
||||||
|
- `src/lib/server/pipeline/indexing.pipeline.ts`
|
||||||
|
- `src/lib/server/pipeline/job-queue.ts`
|
||||||
|
- `src/lib/server/pipeline/startup.ts`
|
||||||
|
- `src/lib/server/pipeline/worker-entry.ts`
|
||||||
|
- `src/lib/server/pipeline/embed-worker-entry.ts`
|
||||||
|
- `src/lib/server/pipeline/differential-strategy.ts`
|
||||||
|
- `src/lib/server/search/vector.search.ts`
|
||||||
|
- `src/lib/server/search/hybrid.search.service.ts`
|
||||||
|
- `src/lib/server/search/search.service.ts`
|
||||||
|
- `src/lib/server/services/repository.service.ts`
|
||||||
|
- `src/lib/server/services/version.service.ts`
|
||||||
|
- `src/lib/server/services/embedding-settings.service.ts`
|
||||||
|
|
||||||
|
Affected test files (same mechanical replacement):
|
||||||
|
- `src/routes/api/v1/api-contract.integration.test.ts`
|
||||||
|
- `src/routes/api/v1/sse-and-settings.integration.test.ts`
|
||||||
|
- `src/routes/settings/page.server.test.ts`
|
||||||
|
- `src/lib/server/db/schema.test.ts`
|
||||||
|
- `src/lib/server/embeddings/embedding.service.test.ts`
|
||||||
|
- `src/lib/server/pipeline/indexing.pipeline.test.ts`
|
||||||
|
- `src/lib/server/pipeline/differential-strategy.test.ts`
|
||||||
|
- `src/lib/server/search/search.service.test.ts`
|
||||||
|
- `src/lib/server/search/hybrid.search.service.test.ts`
|
||||||
|
- `src/lib/server/services/repository.service.test.ts`
|
||||||
|
- `src/lib/server/services/version.service.test.ts`
|
||||||
|
- `src/routes/api/v1/settings/embedding/server.test.ts`
|
||||||
|
- `src/routes/api/v1/libs/[id]/index/server.test.ts`
|
||||||
|
- `src/routes/api/v1/libs/[id]/versions/discover/server.test.ts`
|
||||||
|
|
||||||
|
3. Run all tests — they should pass with zero logic changes: `npm test`
|
||||||
|
|
||||||
|
### Phase 2 — Pragma Hardening
|
||||||
|
|
||||||
|
**Files touched:** `src/lib/server/db/client.ts`, `src/lib/server/db/index.ts`
|
||||||
|
|
||||||
|
Add the following pragmas to both connection factories (raw client and `initializeDatabase()`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
client.pragma('synchronous = NORMAL');
|
||||||
|
client.pragma('cache_size = -65536'); // 64 MB
|
||||||
|
client.pragma('temp_store = MEMORY');
|
||||||
|
client.pragma('mmap_size = 268435456'); // 256 MB
|
||||||
|
client.pragma('wal_autocheckpoint = 1000');
|
||||||
|
```
|
||||||
|
|
||||||
|
Worker threads (`worker-entry.ts`, `embed-worker-entry.ts`) open their own connections — apply the same pragmas there.
|
||||||
|
|
||||||
|
### Phase 3 — Composite Indexes (Drizzle migration)
|
||||||
|
|
||||||
|
**Files touched:** `src/lib/server/db/schema.ts`, new migration SQL file
|
||||||
|
|
||||||
|
Add indexes in `schema.ts` using Drizzle's `index()` helper:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// snippets table
|
||||||
|
export const snippets = sqliteTable(
|
||||||
|
'snippets',
|
||||||
|
{
|
||||||
|
/* unchanged */
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('idx_snippets_repo_version').on(t.repositoryId, t.versionId),
|
||||||
|
index('idx_snippets_repo_type').on(t.repositoryId, t.type)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// documents table
|
||||||
|
export const documents = sqliteTable(
|
||||||
|
'documents',
|
||||||
|
{
|
||||||
|
/* unchanged */
|
||||||
|
},
|
||||||
|
(t) => [index('idx_documents_repo_version').on(t.repositoryId, t.versionId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// snippet_embeddings table
|
||||||
|
export const snippetEmbeddings = sqliteTable(
|
||||||
|
'snippet_embeddings',
|
||||||
|
{
|
||||||
|
/* unchanged */
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({ columns: [table.snippetId, table.profileId] }), // unchanged
|
||||||
|
index('idx_embeddings_profile').on(table.profileId, table.snippetId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// repositories table
|
||||||
|
export const repositories = sqliteTable(
|
||||||
|
'repositories',
|
||||||
|
{
|
||||||
|
/* unchanged */
|
||||||
|
},
|
||||||
|
(t) => [index('idx_repositories_state').on(t.state)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// indexing_jobs table
|
||||||
|
export const indexingJobs = sqliteTable(
|
||||||
|
'indexing_jobs',
|
||||||
|
{
|
||||||
|
/* unchanged */
|
||||||
|
},
|
||||||
|
(t) => [index('idx_jobs_repo_status').on(t.repositoryId, t.status)]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate and apply migration: `npm run db:generate && npm run db:migrate`
|
||||||
|
|
||||||
|
### Phase 4 — Vector Column and Index (Drizzle migration)
|
||||||
|
|
||||||
|
**Files touched:** `src/lib/server/db/schema.ts`, new migration SQL, `src/lib/server/search/vector.search.ts`, `src/lib/server/embeddings/embedding.service.ts`
|
||||||
|
|
||||||
|
#### 4a. Schema: add `vec_embedding` column
|
||||||
|
|
||||||
|
Add `vec_embedding` to `snippet_embeddings`. Drizzle does not have a `F32_BLOB` column type helper; use a raw SQL column:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { customType } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
const f32Blob = (name: string, dimensions: number) =>
|
||||||
|
customType<{ data: Buffer }>({
|
||||||
|
dataType() {
|
||||||
|
return `F32_BLOB(${dimensions})`;
|
||||||
|
}
|
||||||
|
})(name);
|
||||||
|
|
||||||
|
export const snippetEmbeddings = sqliteTable(
|
||||||
|
'snippet_embeddings',
|
||||||
|
{
|
||||||
|
snippetId: text('snippet_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => snippets.id, { onDelete: 'cascade' }),
|
||||||
|
profileId: text('profile_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => embeddingProfiles.id, { onDelete: 'cascade' }),
|
||||||
|
model: text('model').notNull(),
|
||||||
|
dimensions: integer('dimensions').notNull(),
|
||||||
|
embedding: blob('embedding').notNull(), // existing blob — kept for backward compat
|
||||||
|
vecEmbedding: f32Blob('vec_embedding', 1536), // libSQL vector column (nullable during migration fill)
|
||||||
|
createdAt: integer('created_at').notNull()
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({ columns: [table.snippetId, table.profileId] }),
|
||||||
|
index('idx_embeddings_profile').on(table.profileId, table.snippetId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Because dimensionality is fixed per model, `F32_BLOB(1536)` covers OpenAI `text-embedding-3-small/large`. A follow-up can parameterize this per profile.
|
||||||
|
|
||||||
|
#### 4b. Migration SQL: populate `vec_embedding` from existing `embedding` blob and create the vector index
|
||||||
|
|
||||||
|
The vector index cannot be expressed in SQL DDL portable across Drizzle — it must be applied in the FTS-style custom SQL file (`src/lib/server/db/fts.sql` or an equivalent `vectors.sql`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Backfill vec_embedding from existing raw blob data
|
||||||
|
UPDATE snippet_embeddings
|
||||||
|
SET vec_embedding = vector_from_float32(embedding)
|
||||||
|
WHERE vec_embedding IS NULL AND embedding IS NOT NULL;
|
||||||
|
|
||||||
|
-- Create the HNSW vector index (libSQL extension syntax)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snippet_embeddings_vec
|
||||||
|
ON snippet_embeddings(vec_embedding)
|
||||||
|
USING libsql_vector_idx(vec_embedding, 'metric=cosine', 'compress_neighbors=float8', 'max_neighbors=20');
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a call to this SQL in `initializeDatabase()` alongside the existing `fts.sql` execution:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const vectorSql = readFileSync(join(__dirname, 'vectors.sql'), 'utf-8');
|
||||||
|
client.exec(vectorSql);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4c. Update `EmbeddingService.embedSnippets()`
|
||||||
|
|
||||||
|
When inserting a new embedding, write both the blob and the vec column:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const insert = this.db.prepare<[string, string, string, number, Buffer, Buffer]>(`
|
||||||
|
INSERT OR REPLACE INTO snippet_embeddings
|
||||||
|
(snippet_id, profile_id, model, dimensions, embedding, vec_embedding, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, vector_from_float32(?), unixepoch())
|
||||||
|
`);
|
||||||
|
|
||||||
|
// inside the transaction:
|
||||||
|
insert.run(
|
||||||
|
snippet.id,
|
||||||
|
this.profileId,
|
||||||
|
embedding.model,
|
||||||
|
embedding.dimensions,
|
||||||
|
embeddingBuffer,
|
||||||
|
embeddingBuffer // same bytes — vector_from_float32() interprets them
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4d. Rewrite `VectorSearch.vectorSearch()`
|
||||||
|
|
||||||
|
Replace the full-scan JS loop with `vector_top_k()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
vectorSearch(queryEmbedding: Float32Array, options: VectorSearchOptions): VectorSearchResult[] {
|
||||||
|
const { repositoryId, versionId, profileId = 'local-default', limit = 50 } = options;
|
||||||
|
|
||||||
|
const queryBytes = Buffer.from(queryEmbedding.buffer);
|
||||||
|
const candidatePool = limit * 4; // over-fetch for post-filter
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT se.snippet_id,
|
||||||
|
vector_distance_cos(se.vec_embedding, vector_from_float32(?)) AS distance
|
||||||
|
FROM vector_top_k('idx_snippet_embeddings_vec', vector_from_float32(?), ?) AS knn
|
||||||
|
JOIN snippet_embeddings se ON se.rowid = knn.id
|
||||||
|
JOIN snippets s ON s.id = se.snippet_id
|
||||||
|
WHERE s.repository_id = ?
|
||||||
|
AND se.profile_id = ?
|
||||||
|
`;
|
||||||
|
const params: unknown[] = [queryBytes, queryBytes, candidatePool, repositoryId, profileId];
|
||||||
|
|
||||||
|
if (versionId) {
|
||||||
|
sql += ' AND s.version_id = ?';
|
||||||
|
params.push(versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY distance ASC LIMIT ?';
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
return this.db
|
||||||
|
.prepare<unknown[], { snippet_id: string; distance: number }>(sql)
|
||||||
|
.all(...params)
|
||||||
|
.map((row) => ({ snippetId: row.snippet_id, score: 1 - row.distance }));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `score` contract is preserved (1 = identical, 0 = orthogonal). The `cosineSimilarity` helper function is no longer called at runtime but can be kept for unit tests.
|
||||||
|
|
||||||
|
### Phase 5 — Per-Job Serialization Key Fix
|
||||||
|
|
||||||
|
**Files touched:** `src/lib/server/pipeline/worker-pool.ts`
|
||||||
|
|
||||||
|
The current serialization guard uses a bare `repositoryId`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// current
|
||||||
|
private runningRepoIds = new Set<string>();
|
||||||
|
// blocks any job whose repositoryId is already in the set
|
||||||
|
const jobIdx = this.jobQueue.findIndex((j) => !this.runningRepoIds.has(j.repositoryId));
|
||||||
|
```
|
||||||
|
|
||||||
|
Different tags of the same repository write to completely disjoint rows (`version_id`-partitioned documents, snippets, and embeddings). The only genuine conflict is two jobs for the same `(repositoryId, versionId)` pair, which `JobQueue.enqueue()` already prevents via the `status IN ('queued', 'running')` deduplication check.
|
||||||
|
|
||||||
|
Change the guard to key on the compound pair:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// replace Set<string> with Set<string> keyed on compound pair
|
||||||
|
private runningJobKeys = new Set<string>();
|
||||||
|
|
||||||
|
private jobKey(repositoryId: string, versionId?: string | null): string {
|
||||||
|
return `${repositoryId}|${versionId ?? ''}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update all four sites that read/write `runningRepoIds`:
|
||||||
|
|
||||||
|
| Location | Old | New |
|
||||||
|
| ------------------------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `dispatch()` find | `!this.runningRepoIds.has(j.repositoryId)` | `!this.runningJobKeys.has(this.jobKey(j.repositoryId, j.versionId))` |
|
||||||
|
| `dispatch()` add | `this.runningRepoIds.add(job.repositoryId)` | `this.runningJobKeys.add(this.jobKey(job.repositoryId, job.versionId))` |
|
||||||
|
| `onWorkerMessage` done/failed delete | `this.runningRepoIds.delete(runningJob.repositoryId)` | `this.runningJobKeys.delete(this.jobKey(runningJob.repositoryId, runningJob.versionId))` |
|
||||||
|
| `onWorkerExit` delete | same | same |
|
||||||
|
|
||||||
|
The `QueuedJob` and `RunningJob` interfaces already carry `versionId` — no type changes needed.
|
||||||
|
|
||||||
|
The only serialized case that remains is `versionId = null` (default-branch re-index) paired with itself, which maps to the stable key `"repositoryId|"` — correctly deduplicated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6 — Dedicated Write Worker (Single-Writer Pattern)
|
||||||
|
|
||||||
|
**Files touched:** `src/lib/server/pipeline/worker-types.ts`, `src/lib/server/pipeline/write-worker-entry.ts` (new), `src/lib/server/pipeline/worker-entry.ts`, `src/lib/server/pipeline/worker-pool.ts`
|
||||||
|
|
||||||
|
#### Motivation
|
||||||
|
|
||||||
|
With Phase 5 in place, N tags of the same library can index in parallel. Each parse worker currently opens its own DB connection and holds the write lock while storing parsed snippets. Under N concurrent writers, each worker spends the majority of its wall-clock time waiting in `busy_timeout` back-off. The fix is the single-writer pattern: one dedicated write worker owns the only writable DB connection; parse workers become stateless CPU workers that send write batches over `postMessage`.
|
||||||
|
|
||||||
|
```
|
||||||
|
Parse Worker 1 ──┐ WriteRequest (docs[], snippets[]) ┌── WriteAck
|
||||||
|
Parse Worker 2 ──┼─────────────────────────────────────► Write Worker (sole DB writer)
|
||||||
|
Parse Worker N ──┘ └── single better-sqlite3 connection
|
||||||
|
```
|
||||||
|
|
||||||
|
#### New message types (`worker-types.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface WriteRequest {
|
||||||
|
type: 'write';
|
||||||
|
jobId: string;
|
||||||
|
documents: SerializedDocument[];
|
||||||
|
snippets: SerializedSnippet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteAck {
|
||||||
|
type: 'write_ack';
|
||||||
|
jobId: string;
|
||||||
|
documentCount: number;
|
||||||
|
snippetCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteError {
|
||||||
|
type: 'write_error';
|
||||||
|
jobId: string;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializedDocument / SerializedSnippet mirror the DB column shapes
|
||||||
|
// (plain objects, safe to transfer via structured clone)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Write worker (`write-worker-entry.ts`)
|
||||||
|
|
||||||
|
The write worker:
|
||||||
|
|
||||||
|
- Opens its own `Database` connection (WAL mode, all pragmas from Phase 2)
|
||||||
|
- Listens for `WriteRequest` messages
|
||||||
|
- Wraps each batch in a single transaction
|
||||||
|
- Posts `WriteAck` or `WriteError` back to the parent, which forwards the ack to the originating parse worker by `jobId`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Database from '@libsql/better-sqlite3';
|
||||||
|
import { workerData, parentPort } from 'node:worker_threads';
|
||||||
|
import type { WriteRequest, WriteAck, WriteError } from './worker-types.js';
|
||||||
|
|
||||||
|
const db = new Database((workerData as WorkerInitData).dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('synchronous = NORMAL');
|
||||||
|
db.pragma('cache_size = -65536');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
const insertDoc = db.prepare(`INSERT OR REPLACE INTO documents (...) VALUES (...)`);
|
||||||
|
const insertSnippet = db.prepare(`INSERT OR REPLACE INTO snippets (...) VALUES (...)`);
|
||||||
|
|
||||||
|
const writeBatch = db.transaction((req: WriteRequest) => {
|
||||||
|
for (const doc of req.documents) insertDoc.run(doc);
|
||||||
|
for (const snip of req.snippets) insertSnippet.run(snip);
|
||||||
|
});
|
||||||
|
|
||||||
|
parentPort!.on('message', (req: WriteRequest) => {
|
||||||
|
try {
|
||||||
|
writeBatch(req);
|
||||||
|
const ack: WriteAck = {
|
||||||
|
type: 'write_ack',
|
||||||
|
jobId: req.jobId,
|
||||||
|
documentCount: req.documents.length,
|
||||||
|
snippetCount: req.snippets.length
|
||||||
|
};
|
||||||
|
parentPort!.postMessage(ack);
|
||||||
|
} catch (err) {
|
||||||
|
const fail: WriteError = { type: 'write_error', jobId: req.jobId, error: String(err) };
|
||||||
|
parentPort!.postMessage(fail);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parse worker changes (`worker-entry.ts`)
|
||||||
|
|
||||||
|
Parse workers lose their DB connection. `IndexingPipeline` receives a `sendWrite` callback instead of a `db` instance. After parsing each file batch, the worker calls `sendWrite({ type: 'write', jobId, documents, snippets })` and awaits the `WriteAck` before continuing. This keeps back-pressure: a slow write worker naturally throttles the parse workers without additional semaphores.
|
||||||
|
|
||||||
|
#### WorkerPool changes
|
||||||
|
|
||||||
|
- Spawn one write worker at startup (always, regardless of embedding config)
|
||||||
|
- Route incoming `write_ack` / `write_error` messages to the correct waiting parse worker via a `Map<jobId, resolve>` promise registry
|
||||||
|
- The write worker is separate from the embed worker — embed writes (`snippet_embeddings`) can still go through the write worker by adding an `EmbedWriteRequest` message type, or remain in the embed worker since embedding runs after parsing completes (no lock contention with active parse jobs)
|
||||||
|
|
||||||
|
#### Conflict analysis with Phase 5
|
||||||
|
|
||||||
|
Phases 5 and 6 compose cleanly:
|
||||||
|
|
||||||
|
- Phase 5 allows multiple `(repo, versionId)` jobs to run concurrently
|
||||||
|
- Phase 6 ensures all those concurrent jobs share a single write path — contention is eliminated by design
|
||||||
|
- The write worker is stateless with respect to job identity; it just executes batches in arrival order within a FIFO message queue (Node.js `postMessage` is ordered)
|
||||||
|
- The embed worker remains a separate process (it runs after parse completes, so it never overlaps with active parse writes for the same job)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7 — Admin UI Overhaul
|
||||||
|
|
||||||
|
**Files touched:**
|
||||||
|
|
||||||
|
- `src/routes/admin/jobs/+page.svelte` — rebuilt
|
||||||
|
- `src/routes/api/v1/workers/+server.ts` — new endpoint
|
||||||
|
- `src/lib/components/admin/JobStatusBadge.svelte` — extend with spinner variant
|
||||||
|
- `src/lib/components/admin/JobSkeleton.svelte` — new
|
||||||
|
- `src/lib/components/admin/WorkerStatusPanel.svelte` — new
|
||||||
|
- `src/lib/components/admin/Toast.svelte` — new
|
||||||
|
- `src/lib/components/IndexingProgress.svelte` — switch to SSE
|
||||||
|
|
||||||
|
#### 7a. New API endpoint: `GET /api/v1/workers`
|
||||||
|
|
||||||
|
The `WorkerPool` singleton tracks running jobs in `runningJobs: Map<Worker, RunningJob>` and idle workers in `idleWorkers: Worker[]`. Expose this state as a lightweight REST snapshot:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// GET /api/v1/workers
|
||||||
|
// Response shape:
|
||||||
|
interface WorkersResponse {
|
||||||
|
concurrency: number; // configured max workers
|
||||||
|
active: number; // workers with a running job
|
||||||
|
idle: number; // workers waiting for work
|
||||||
|
workers: WorkerStatus[]; // one entry per spawned parse worker
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerStatus {
|
||||||
|
index: number; // worker slot (0-based)
|
||||||
|
state: 'idle' | 'running'; // current state
|
||||||
|
jobId: string | null; // null when idle
|
||||||
|
repositoryId: string | null;
|
||||||
|
versionId: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The route handler calls `getPool().getStatus()` — add a `getStatus(): WorkersResponse` method to `WorkerPool` that reads `runningJobs` and `idleWorkers` without any DB call. This is read-only and runs on the main thread.
|
||||||
|
|
||||||
|
The SSE stream at `/api/v1/jobs/stream` should emit a new `worker-status` event type whenever a worker transitions idle ↔ running (on `dispatch()` and job completion). This allows the worker panel to update in real-time without polling the REST endpoint.
|
||||||
|
|
||||||
|
#### 7b. `GET /api/v1/jobs` — add `repositoryId` free-text and multi-status filter
|
||||||
|
|
||||||
|
The existing endpoint already accepts `repositoryId` (exact match) and `status` (single value). Extend:
|
||||||
|
|
||||||
|
- `repositoryId` to also support prefix match (e.g. `?repositoryId=/facebook` returns all `/facebook/*` repos)
|
||||||
|
- `status` to accept comma-separated values: `?status=queued,running`
|
||||||
|
- `page` and `pageSize` query params (default pageSize=50, max 200) in addition to `limit` for backwards compat
|
||||||
|
|
||||||
|
Return `{ jobs, total, page, pageSize }` with `total` always reflecting the unfiltered-by-page count.
|
||||||
|
|
||||||
|
#### 7c. New component: `JobSkeleton.svelte`
|
||||||
|
|
||||||
|
A set of skeleton rows matching the job table structure. Shown during the initial fetch before any data arrives. Uses Tailwind `animate-pulse`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- renders N skeleton rows -->
|
||||||
|
<script lang="ts">
|
||||||
|
let { rows = 5 }: { rows?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each Array(rows) as _, i (i)}
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="h-4 w-48 animate-pulse rounded bg-gray-200"></div>
|
||||||
|
<div class="mt-1 h-3 w-24 animate-pulse rounded bg-gray-100"></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="h-5 w-16 animate-pulse rounded-full bg-gray-200"></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="h-4 w-20 animate-pulse rounded bg-gray-200"></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="h-2 w-32 animate-pulse rounded-full bg-gray-200"></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="h-4 w-28 animate-pulse rounded bg-gray-200"></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right">
|
||||||
|
<div class="ml-auto h-7 w-20 animate-pulse rounded bg-gray-200"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7d. New component: `Toast.svelte`
|
||||||
|
|
||||||
|
Replaces all `alert()` / `console.log()` calls in the jobs page. Renders a fixed-position stack in the bottom-right corner. Each toast auto-dismisses after 4 seconds and can be manually closed:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- Usage: bind a toasts array and call push({ message, type }) -->
|
||||||
|
<script lang="ts">
|
||||||
|
export interface ToastItem {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { toasts = $bindable([]) }: { toasts: ToastItem[] } = $props();
|
||||||
|
|
||||||
|
function dismiss(id: string) {
|
||||||
|
toasts = toasts.filter((t) => t.id !== id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed right-4 bottom-4 z-50 flex flex-col gap-2">
|
||||||
|
{#each toasts as toast (toast.id)}
|
||||||
|
<!-- color by type, close button, auto-dismiss via onmount timer -->
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The jobs page replaces `showToast()` with pushing onto the bound `toasts` array. The `confirm()` for cancel is replaced with an inline confirmation state per job (`pendingCancelId`) that shows "Confirm cancel?" / "Yes" / "No" buttons inside the row.
|
||||||
|
|
||||||
|
#### 7e. New component: `WorkerStatusPanel.svelte`
|
||||||
|
|
||||||
|
A compact panel displayed above the job table showing the worker pool health. Subscribes to the `worker-status` SSE events and falls back to polling `GET /api/v1/workers` every 5 s on SSE error:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Workers [2 / 4 active] ████░░░░ 50% │
|
||||||
|
│ Worker 0 ● running /facebook/react / v18.3.0 │
|
||||||
|
│ Worker 1 ● running /facebook/react / v17.0.2 │
|
||||||
|
│ Worker 2 ○ idle │
|
||||||
|
│ Worker 3 ○ idle │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Each worker row shows: slot index, status dot (animated green pulse for running), repository ID, version tag, and a link to the job row in the table below.
|
||||||
|
|
||||||
|
#### 7f. Filter bar on the jobs page
|
||||||
|
|
||||||
|
Add a filter strip between the page header and the table:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Repository: _______________ ] [ Status: ▾ all ] [ 🔍 Apply ] [ ↺ Reset ]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Repository field**: free-text input, matches `repositoryId` prefix (e.g. `/facebook` shows all `/facebook/*`)
|
||||||
|
- **Status dropdown**: multi-select checkboxes for `queued`, `running`, `paused`, `cancelled`, `done`, `failed`; default = all
|
||||||
|
- Filters are applied client-side against the loaded `jobs` array for instant feedback, and also re-fetched from the API on Apply to get the correct total count
|
||||||
|
- Filter state is mirrored to URL search params (`?repo=...&status=...`) so the view is bookmarkable and survives refresh
|
||||||
|
|
||||||
|
#### 7g. Per-job action spinner and disabled state
|
||||||
|
|
||||||
|
Replace the single `actionInProgress: string | null` with a `Map<string, 'pausing' | 'resuming' | 'cancelling'>`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let actionInProgress = $state(new Map<string, 'pausing' | 'resuming' | 'cancelling'>());
|
||||||
|
```
|
||||||
|
|
||||||
|
Each action button shows an inline spinner (small `animate-spin` circle) and is disabled only for that row. Other rows remain fully interactive during the action. On completion the entry is deleted from the map.
|
||||||
|
|
||||||
|
#### 7h. `IndexingProgress.svelte` — switch from polling to SSE
|
||||||
|
|
||||||
|
The component currently uses `setInterval + fetch` at 2 s. Replace with the per-job SSE stream already available at `/api/v1/jobs/{id}/stream`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// replace the $effect body
|
||||||
|
$effect(() => {
|
||||||
|
job = null;
|
||||||
|
const es = new EventSource(`/api/v1/jobs/${jobId}/stream`);
|
||||||
|
|
||||||
|
es.addEventListener('job-progress', (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
job = { ...job, ...data };
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('job-done', () => {
|
||||||
|
void fetch(`/api/v1/jobs/${jobId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
job = d.job;
|
||||||
|
oncomplete?.();
|
||||||
|
});
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('job-failed', (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
job = { ...job, status: 'failed', error: data.error };
|
||||||
|
oncomplete?.();
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
// on SSE failure fall back to a single fetch to get current state
|
||||||
|
es.close();
|
||||||
|
void fetch(`/api/v1/jobs/${jobId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
job = d.job;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => es.close();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This reduces network traffic from 1 request/2 s to zero requests during active indexing — updates arrive as server-push events.
|
||||||
|
|
||||||
|
#### 7i. Pagination on the jobs page
|
||||||
|
|
||||||
|
Replace the hard-coded `?limit=50` fetch with paginated requests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let currentPage = $state(1);
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
async function fetchJobs() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(currentPage),
|
||||||
|
pageSize: String(PAGE_SIZE),
|
||||||
|
...(filterRepo ? { repositoryId: filterRepo } : {}),
|
||||||
|
...(filterStatuses.length ? { status: filterStatuses.join(',') } : {})
|
||||||
|
});
|
||||||
|
const data = await fetch(`/api/v1/jobs?${params}`).then((r) => r.json());
|
||||||
|
jobs = data.jobs;
|
||||||
|
total = data.total;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Render a simple `« Prev Page N of M Next »` control below the table, hidden when `total <= PAGE_SIZE`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `npm install` with `@libsql/better-sqlite3` succeeds; `better-sqlite3` is absent from `node_modules`
|
||||||
|
- [ ] All existing unit and integration tests pass after Phase 1 import swap
|
||||||
|
- [ ] `npm run db:migrate` applies the composite index migration cleanly against an existing database
|
||||||
|
- [ ] `npm run db:migrate` applies the vector column migration cleanly; `sql> SELECT vec_embedding FROM snippet_embeddings LIMIT 1` returns a non-NULL value for any previously-embedded snippet
|
||||||
|
- [ ] `GET /api/v1/context?libraryId=...&query=...` with a semantic-mode or hybrid-mode request returns results in ≤ 200 ms on a repository with 50k+ snippets (vs previous multi-second response)
|
||||||
|
- [ ] Memory profiled during a /context request shows no allocation spike proportional to repository size
|
||||||
|
- [ ] `EXPLAIN QUERY PLAN` on the `snippets` search query shows `SCAN snippets USING INDEX idx_snippets_repo_version` instead of `SCAN snippets`
|
||||||
|
- [ ] Worker threads (`worker-entry.ts`, `embed-worker-entry.ts`) start and complete an indexing job successfully after the package swap
|
||||||
|
- [ ] `drizzle-kit studio` connects and browses the migrated database
|
||||||
|
- [ ] Re-indexing a repository after the migration correctly populates `vec_embedding` on all new snippets
|
||||||
|
- [ ] `cosineSimilarity` unit tests still pass (function is kept)
|
||||||
|
- [ ] Starting two indexing jobs for different tags of the same repository simultaneously results in both jobs reaching `running` state concurrently (not one waiting for the other)
|
||||||
|
- [ ] Starting two indexing jobs for the **same** `(repositoryId, versionId)` pair returns the existing job (deduplication unchanged)
|
||||||
|
- [ ] With 4 parse workers and 4 concurrent tag jobs, zero `SQLITE_BUSY` errors appear in logs
|
||||||
|
- [ ] Write worker is present in the process list during active indexing (`worker_threads` inspector shows `write-worker-entry`)
|
||||||
|
- [ ] A `WriteError` from the write worker marks the originating job as `failed` with the error message propagated to the SSE stream
|
||||||
|
- [ ] `GET /api/v1/workers` returns a `WorkersResponse` JSON object with correct `active`, `idle`, and `workers[]` fields while jobs are in-flight
|
||||||
|
- [ ] The `worker-status` SSE event is emitted by `/api/v1/jobs/stream` whenever a worker transitions state
|
||||||
|
- [ ] The admin jobs page shows skeleton rows (not a blank screen) during the initial `fetchJobs()` call
|
||||||
|
- [ ] No `alert()` or `confirm()` calls exist in `admin/jobs/+page.svelte` after this change; all notifications go through `Toast.svelte`
|
||||||
|
- [ ] Pausing job A while job B is also in progress does not disable job B's action buttons
|
||||||
|
- [ ] The status filter multi-select correctly restricts the visible job list; the URL updates to reflect the filter state
|
||||||
|
- [ ] The repository prefix filter `?repositoryId=/facebook` returns all jobs whose `repositoryId` starts with `/facebook`
|
||||||
|
- [ ] Paginating past page 1 fetches the next batch from the API, not from the client-side array
|
||||||
|
- [ ] `IndexingProgress.svelte` has no `setInterval` call; it uses `EventSource` for progress updates
|
||||||
|
- [ ] The `WorkerStatusPanel` shows the correct number of running workers live during a multi-tag indexing run
|
||||||
|
- [ ] Refreshing the jobs page with `?repo=/facebook/react&status=running` pre-populates the filters and fetches with those params
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Safety
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
|
||||||
|
The `embedding` blob column is kept. The `vec_embedding` column is nullable during the backfill window and becomes populated as:
|
||||||
|
|
||||||
|
1. The `UPDATE` in `vectors.sql` fills all existing rows on startup
|
||||||
|
2. New embeddings populate it at insert time
|
||||||
|
|
||||||
|
If `vec_embedding IS NULL` for a row (e.g., a row inserted before the migration runs), the vector index silently omits that row from results. The fallback in `HybridSearchService` to FTS-only mode still applies when no embeddings exist, so degraded-but-correct behavior is preserved.
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
Rollback before Phase 4 (vector column): remove `@libsql/better-sqlite3`, restore `better-sqlite3`, restore imports. No schema changes have been made.
|
||||||
|
|
||||||
|
Rollback after Phase 4: schema now has `vec_embedding` column. Drop the column with a migration reversal and restore imports. The `embedding` blob is intact throughout — no data loss.
|
||||||
|
|
||||||
|
### SQLite File Compatibility
|
||||||
|
|
||||||
|
libSQL embedded mode reads and writes standard SQLite 3 files. The WAL file, page size, and encoding are unchanged. An existing production database opened with `@libsql/better-sqlite3` is fully readable and writable. The vector index is stored in a shadow table `idx_snippet_embeddings_vec_shadow` which better-sqlite3 would ignore if rolled back (it is a regular table with a special name).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Package | Action | Reason |
|
||||||
|
| ------------------------ | ----------------------------- | ----------------------------------------------- |
|
||||||
|
| `better-sqlite3` | Remove from `dependencies` | Replaced |
|
||||||
|
| `@types/better-sqlite3` | Remove from `devDependencies` | `@libsql/better-sqlite3` ships own types |
|
||||||
|
| `@libsql/better-sqlite3` | Add to `dependencies` | Drop-in libSQL node addon |
|
||||||
|
| `drizzle-orm` | No change | `better-sqlite3` adapter works unchanged |
|
||||||
|
| `drizzle-kit` | No change | `dialect: 'sqlite'` correct for embedded libSQL |
|
||||||
|
|
||||||
|
No new runtime dependencies beyond the package replacement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
- `src/lib/server/search/vector.search.ts`: add test asserting KNN results are correct for a seeded 3-vector table; verify memory is not proportional to table size (mock `db.prepare` to assert no unbounded `.all()` is called)
|
||||||
|
- `src/lib/server/embeddings/embedding.service.ts`: existing tests cover insert round-trips; verify `vec_embedding` column is non-NULL after `embedSnippets()`
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- `api-contract.integration.test.ts`: existing tests already use `new Database(':memory:')` — these continue to work with `@libsql/better-sqlite3` because the in-memory path is identical
|
||||||
|
- Add one test to `api-contract.integration.test.ts`: seed a repository + multiple embeddings, call `/api/v1/context` in semantic mode, assert non-empty results and response time < 500ms on in-memory DB
|
||||||
|
|
||||||
|
### UI Tests
|
||||||
|
|
||||||
|
- `src/routes/admin/jobs/+page.svelte`: add Vitest browser tests (Playwright) verifying:
|
||||||
|
- Skeleton rows appear before the first fetch resolves (mock `fetch` to delay 200 ms)
|
||||||
|
- Status filter restricts displayed rows; URL param updates
|
||||||
|
- Pausing job A leaves job B's buttons enabled
|
||||||
|
- Toast appears and auto-dismisses on successful pause
|
||||||
|
- Cancel confirm flow shows inline confirmation, not `window.confirm`
|
||||||
|
- `src/lib/components/IndexingProgress.svelte`: unit test that no `setInterval` is created; verify `EventSource` is opened with the correct URL
|
||||||
|
|
||||||
|
### Performance Regression Gate
|
||||||
|
|
||||||
|
Add a benchmark script `scripts/bench-vector-search.mjs` that:
|
||||||
|
|
||||||
|
1. Creates an in-memory libSQL database
|
||||||
|
2. Seeds 10000 snippet embeddings (random Float32Array, 1536 dims)
|
||||||
|
3. Runs 100 `vectorSearch()` calls
|
||||||
|
4. Asserts p99 < 50 ms
|
||||||
|
|
||||||
|
This gates the CI check on Phase 4 correctness and speed.
|
||||||
754
package-lock.json
generated
754
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"sqlite-vec": "^0.1.9",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"@vitest/browser-playwright": "^4.1.0",
|
"@vitest/browser-playwright": "^4.1.0",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"esbuild": "^0.24.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.14.0",
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
@@ -494,9 +496,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -511,9 +513,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -528,9 +530,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -545,9 +547,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -562,9 +564,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -579,9 +581,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -596,9 +598,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -613,9 +615,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -630,9 +632,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -647,9 +649,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -664,9 +666,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -681,9 +683,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -698,9 +700,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -715,9 +717,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -732,9 +734,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -749,9 +751,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -766,9 +768,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -783,9 +785,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -800,9 +802,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -817,9 +819,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -834,9 +836,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -868,9 +870,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -885,9 +887,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -902,9 +904,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -919,9 +921,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3537,6 +3539,473 @@
|
|||||||
"drizzle-kit": "bin.cjs"
|
"drizzle-kit": "bin.cjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/drizzle-kit/node_modules/esbuild": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.12",
|
||||||
|
"@esbuild/android-arm": "0.25.12",
|
||||||
|
"@esbuild/android-arm64": "0.25.12",
|
||||||
|
"@esbuild/android-x64": "0.25.12",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.12",
|
||||||
|
"@esbuild/darwin-x64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.12",
|
||||||
|
"@esbuild/linux-arm": "0.25.12",
|
||||||
|
"@esbuild/linux-arm64": "0.25.12",
|
||||||
|
"@esbuild/linux-ia32": "0.25.12",
|
||||||
|
"@esbuild/linux-loong64": "0.25.12",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.12",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.12",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.12",
|
||||||
|
"@esbuild/linux-s390x": "0.25.12",
|
||||||
|
"@esbuild/linux-x64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openharmony-arm64": "0.25.12",
|
||||||
|
"@esbuild/sunos-x64": "0.25.12",
|
||||||
|
"@esbuild/win32-arm64": "0.25.12",
|
||||||
|
"@esbuild/win32-ia32": "0.25.12",
|
||||||
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/drizzle-orm": {
|
"node_modules/drizzle-orm": {
|
||||||
"version": "0.45.1",
|
"version": "0.45.1",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz",
|
||||||
@@ -3753,9 +4222,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3766,32 +4235,31 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.25.12",
|
"@esbuild/aix-ppc64": "0.24.2",
|
||||||
"@esbuild/android-arm": "0.25.12",
|
"@esbuild/android-arm": "0.24.2",
|
||||||
"@esbuild/android-arm64": "0.25.12",
|
"@esbuild/android-arm64": "0.24.2",
|
||||||
"@esbuild/android-x64": "0.25.12",
|
"@esbuild/android-x64": "0.24.2",
|
||||||
"@esbuild/darwin-arm64": "0.25.12",
|
"@esbuild/darwin-arm64": "0.24.2",
|
||||||
"@esbuild/darwin-x64": "0.25.12",
|
"@esbuild/darwin-x64": "0.24.2",
|
||||||
"@esbuild/freebsd-arm64": "0.25.12",
|
"@esbuild/freebsd-arm64": "0.24.2",
|
||||||
"@esbuild/freebsd-x64": "0.25.12",
|
"@esbuild/freebsd-x64": "0.24.2",
|
||||||
"@esbuild/linux-arm": "0.25.12",
|
"@esbuild/linux-arm": "0.24.2",
|
||||||
"@esbuild/linux-arm64": "0.25.12",
|
"@esbuild/linux-arm64": "0.24.2",
|
||||||
"@esbuild/linux-ia32": "0.25.12",
|
"@esbuild/linux-ia32": "0.24.2",
|
||||||
"@esbuild/linux-loong64": "0.25.12",
|
"@esbuild/linux-loong64": "0.24.2",
|
||||||
"@esbuild/linux-mips64el": "0.25.12",
|
"@esbuild/linux-mips64el": "0.24.2",
|
||||||
"@esbuild/linux-ppc64": "0.25.12",
|
"@esbuild/linux-ppc64": "0.24.2",
|
||||||
"@esbuild/linux-riscv64": "0.25.12",
|
"@esbuild/linux-riscv64": "0.24.2",
|
||||||
"@esbuild/linux-s390x": "0.25.12",
|
"@esbuild/linux-s390x": "0.24.2",
|
||||||
"@esbuild/linux-x64": "0.25.12",
|
"@esbuild/linux-x64": "0.24.2",
|
||||||
"@esbuild/netbsd-arm64": "0.25.12",
|
"@esbuild/netbsd-arm64": "0.24.2",
|
||||||
"@esbuild/netbsd-x64": "0.25.12",
|
"@esbuild/netbsd-x64": "0.24.2",
|
||||||
"@esbuild/openbsd-arm64": "0.25.12",
|
"@esbuild/openbsd-arm64": "0.24.2",
|
||||||
"@esbuild/openbsd-x64": "0.25.12",
|
"@esbuild/openbsd-x64": "0.24.2",
|
||||||
"@esbuild/openharmony-arm64": "0.25.12",
|
"@esbuild/sunos-x64": "0.24.2",
|
||||||
"@esbuild/sunos-x64": "0.25.12",
|
"@esbuild/win32-arm64": "0.24.2",
|
||||||
"@esbuild/win32-arm64": "0.25.12",
|
"@esbuild/win32-ia32": "0.24.2",
|
||||||
"@esbuild/win32-ia32": "0.25.12",
|
"@esbuild/win32-x64": "0.24.2"
|
||||||
"@esbuild/win32-x64": "0.25.12"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
@@ -6527,6 +6995,84 @@
|
|||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sqlite-vec": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/sqlite-vec/-/sqlite-vec-0.1.9.tgz",
|
||||||
|
"integrity": "sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==",
|
||||||
|
"license": "MIT OR Apache",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"sqlite-vec-darwin-arm64": "0.1.9",
|
||||||
|
"sqlite-vec-darwin-x64": "0.1.9",
|
||||||
|
"sqlite-vec-linux-arm64": "0.1.9",
|
||||||
|
"sqlite-vec-linux-x64": "0.1.9",
|
||||||
|
"sqlite-vec-windows-x64": "0.1.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sqlite-vec-darwin-arm64": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/sqlite-vec-darwin-arm64/-/sqlite-vec-darwin-arm64-0.1.9.tgz",
|
||||||
|
"integrity": "sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT OR Apache",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sqlite-vec-darwin-x64": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/sqlite-vec-darwin-x64/-/sqlite-vec-darwin-x64-0.1.9.tgz",
|
||||||
|
"integrity": "sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT OR Apache",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sqlite-vec-linux-arm64": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/sqlite-vec-linux-arm64/-/sqlite-vec-linux-arm64-0.1.9.tgz",
|
||||||
|
"integrity": "sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT OR Apache",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sqlite-vec-linux-x64": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/sqlite-vec-linux-x64/-/sqlite-vec-linux-x64-0.1.9.tgz",
|
||||||
|
"integrity": "sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT OR Apache",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sqlite-vec-windows-x64": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/sqlite-vec-windows-x64/-/sqlite-vec-windows-x64-0.1.9.tgz",
|
||||||
|
"integrity": "sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT OR Apache",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/stackback": {
|
"node_modules/stackback": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"sqlite-vec": "^0.1.9",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { existsSync } from 'node:fs';
|
|||||||
|
|
||||||
const entries = [
|
const entries = [
|
||||||
'src/lib/server/pipeline/worker-entry.ts',
|
'src/lib/server/pipeline/worker-entry.ts',
|
||||||
'src/lib/server/pipeline/embed-worker-entry.ts'
|
'src/lib/server/pipeline/embed-worker-entry.ts',
|
||||||
|
'src/lib/server/pipeline/write-worker-entry.ts'
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = entries.filter(e => existsSync(e));
|
const existing = entries.filter((e) => existsSync(e));
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
console.log('[build-workers] No worker entry files found yet, skipping.');
|
console.log('[build-workers] No worker entry files found yet, skipping.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -22,7 +23,7 @@ try {
|
|||||||
outdir: 'build/workers',
|
outdir: 'build/workers',
|
||||||
outExtension: { '.js': '.mjs' },
|
outExtension: { '.js': '.mjs' },
|
||||||
alias: {
|
alias: {
|
||||||
'$lib': './src/lib',
|
$lib: './src/lib',
|
||||||
'$lib/server': './src/lib/server'
|
'$lib/server': './src/lib/server'
|
||||||
},
|
},
|
||||||
external: ['better-sqlite3', '@xenova/transformers'],
|
external: ['better-sqlite3', '@xenova/transformers'],
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ try {
|
|||||||
try {
|
try {
|
||||||
const db = getClient();
|
const db = getClient();
|
||||||
const activeProfileRow = db
|
const activeProfileRow = db
|
||||||
.prepare<[], EmbeddingProfileEntityProps>(
|
.prepare<
|
||||||
'SELECT * FROM embedding_profiles WHERE is_default = 1 AND enabled = 1 LIMIT 1'
|
[],
|
||||||
)
|
EmbeddingProfileEntityProps
|
||||||
|
>('SELECT * FROM embedding_profiles WHERE is_default = 1 AND enabled = 1 LIMIT 1')
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
let embeddingService: EmbeddingService | null = null;
|
let embeddingService: EmbeddingService | null = null;
|
||||||
@@ -55,9 +56,10 @@ try {
|
|||||||
let concurrency = 2; // default
|
let concurrency = 2; // default
|
||||||
if (dbPath) {
|
if (dbPath) {
|
||||||
const concurrencyRow = db
|
const concurrencyRow = db
|
||||||
.prepare<[], { value: string }>(
|
.prepare<
|
||||||
"SELECT value FROM settings WHERE key = 'indexing.concurrency' LIMIT 1"
|
[],
|
||||||
)
|
{ value: string }
|
||||||
|
>("SELECT value FROM settings WHERE key = 'indexing.concurrency' LIMIT 1")
|
||||||
.get();
|
.get();
|
||||||
if (concurrencyRow) {
|
if (concurrencyRow) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,39 +7,41 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
job = null;
|
job = null;
|
||||||
let stopped = false;
|
const es = new EventSource(`/api/v1/jobs/${jobId}/stream`);
|
||||||
let completeFired = false;
|
|
||||||
|
|
||||||
async function poll() {
|
es.addEventListener('job-progress', (event) => {
|
||||||
if (stopped) return;
|
const data = JSON.parse(event.data);
|
||||||
try {
|
job = { ...job, ...data } as IndexingJob;
|
||||||
const res = await fetch(`/api/v1/jobs/${jobId}`);
|
});
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
job = data.job;
|
|
||||||
if (!completeFired && (job?.status === 'done' || job?.status === 'failed')) {
|
|
||||||
completeFired = true;
|
|
||||||
oncomplete?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore transient errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void poll();
|
es.addEventListener('job-done', () => {
|
||||||
const interval = setInterval(() => {
|
void fetch(`/api/v1/jobs/${jobId}`)
|
||||||
if (job?.status === 'done' || job?.status === 'failed') {
|
.then((r) => r.json())
|
||||||
clearInterval(interval);
|
.then((d) => {
|
||||||
return;
|
job = d.job;
|
||||||
}
|
oncomplete?.();
|
||||||
void poll();
|
});
|
||||||
}, 2000);
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
es.addEventListener('job-failed', (event) => {
|
||||||
stopped = true;
|
const data = JSON.parse(event.data);
|
||||||
clearInterval(interval);
|
if (job)
|
||||||
|
job = { ...job, status: 'failed', error: data.error ?? 'Unknown error' } as IndexingJob;
|
||||||
|
oncomplete?.();
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
es.close();
|
||||||
|
void fetch(`/api/v1/jobs/${jobId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
job = d.job;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return () => es.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
const progress = $derived(job?.progress ?? 0);
|
const progress = $derived(job?.progress ?? 0);
|
||||||
|
|||||||
20
src/lib/components/admin/JobSkeleton.svelte
Normal file
20
src/lib/components/admin/JobSkeleton.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { rows = 5 }: { rows?: number } = $props();
|
||||||
|
const rowIndexes = $derived(Array.from({ length: rows }, (_, index) => index));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each rowIndexes as i (i)}
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="h-4 w-48 animate-pulse rounded bg-gray-200"></div>
|
||||||
|
<div class="mt-1 h-3 w-24 animate-pulse rounded bg-gray-100"></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4"><div class="h-5 w-16 animate-pulse rounded-full bg-gray-200"></div></td>
|
||||||
|
<td class="px-6 py-4"><div class="h-4 w-20 animate-pulse rounded bg-gray-200"></div></td>
|
||||||
|
<td class="px-6 py-4"><div class="h-2 w-32 animate-pulse rounded-full bg-gray-200"></div></td>
|
||||||
|
<td class="px-6 py-4"><div class="h-4 w-28 animate-pulse rounded bg-gray-200"></div></td>
|
||||||
|
<td class="px-6 py-4 text-right"
|
||||||
|
><div class="ml-auto h-7 w-20 animate-pulse rounded bg-gray-200"></div></td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
status: 'queued' | 'running' | 'paused' | 'cancelled' | 'done' | 'failed';
|
status: 'queued' | 'running' | 'paused' | 'cancelled' | 'done' | 'failed';
|
||||||
|
spinning?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { status }: Props = $props();
|
let { status, spinning = false }: Props = $props();
|
||||||
|
|
||||||
const statusConfig: Record<typeof status, { bg: string; text: string; label: string }> = {
|
const statusConfig: Record<typeof status, { bg: string; text: string; label: string }> = {
|
||||||
queued: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Queued' },
|
queued: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Queued' },
|
||||||
@@ -21,4 +22,9 @@
|
|||||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {config.bg} {config.text}"
|
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {config.bg} {config.text}"
|
||||||
>
|
>
|
||||||
{config.label}
|
{config.label}
|
||||||
|
{#if spinning}
|
||||||
|
<span
|
||||||
|
class="ml-1 inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-r-transparent"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
77
src/lib/components/admin/Toast.svelte
Normal file
77
src/lib/components/admin/Toast.svelte
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { toasts = $bindable([]) }: { toasts: ToastItem[] } = $props();
|
||||||
|
const timers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
for (const toast of toasts) {
|
||||||
|
if (timers.has(toast.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
dismiss(toast.id);
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
timers.set(toast.id, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, timer] of timers.entries()) {
|
||||||
|
if (toasts.some((toast) => toast.id === id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timer);
|
||||||
|
timers.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
for (const timer of timers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
timers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function dismiss(id: string) {
|
||||||
|
const timer = timers.get(id);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timers.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toasts = toasts.filter((toast: ToastItem) => toast.id !== id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed right-4 bottom-4 z-50 flex flex-col gap-2">
|
||||||
|
{#each toasts as toast (toast.id)}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
class="flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg {toast.type === 'error'
|
||||||
|
? 'bg-red-600 text-white'
|
||||||
|
: toast.type === 'info'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-green-600 text-white'}"
|
||||||
|
>
|
||||||
|
<span class="text-sm">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
onclick={() => dismiss(toast.id)}
|
||||||
|
class="ml-2 text-xs opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
81
src/lib/components/admin/WorkerStatusPanel.svelte
Normal file
81
src/lib/components/admin/WorkerStatusPanel.svelte
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface WorkerStatus {
|
||||||
|
index: number;
|
||||||
|
state: 'idle' | 'running';
|
||||||
|
jobId: string | null;
|
||||||
|
repositoryId: string | null;
|
||||||
|
versionId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkersResponse {
|
||||||
|
concurrency: number;
|
||||||
|
active: number;
|
||||||
|
idle: number;
|
||||||
|
workers: WorkerStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = $state<WorkersResponse>({ concurrency: 0, active: 0, idle: 0, workers: [] });
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/workers');
|
||||||
|
if (res.ok) status = await res.json();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void fetchStatus();
|
||||||
|
const es = new EventSource('/api/v1/jobs/stream');
|
||||||
|
es.addEventListener('worker-status', (event) => {
|
||||||
|
try {
|
||||||
|
status = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
es.onerror = () => {
|
||||||
|
es.close();
|
||||||
|
if (!pollInterval) {
|
||||||
|
pollInterval = setInterval(() => void fetchStatus(), 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
es.close();
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if status.concurrency > 0}
|
||||||
|
<div class="mb-4 rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700">Workers</h3>
|
||||||
|
<span class="text-xs text-gray-500">{status.active} / {status.concurrency} active</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each status.workers as worker (worker.index)}
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
class="flex h-2 w-2 rounded-full {worker.state === 'running'
|
||||||
|
? 'animate-pulse bg-green-500'
|
||||||
|
: 'bg-gray-300'}"
|
||||||
|
></span>
|
||||||
|
<span class="text-gray-600">Worker {worker.index}</span>
|
||||||
|
{#if worker.state === 'running' && worker.repositoryId}
|
||||||
|
<span class="truncate text-gray-400"
|
||||||
|
>{worker.repositoryId}{worker.versionId ? ' / ' + worker.versionId : ''}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-400">idle</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -10,9 +10,7 @@ import { GitHubApiError } from './github-tags.js';
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function mockFetch(status: number, body: unknown): void {
|
function mockFetch(status: number, body: unknown): void {
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
vi.spyOn(global, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify(body), { status }));
|
||||||
new Response(JSON.stringify(body), { status })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -105,9 +103,9 @@ describe('fetchGitHubChangedFiles', () => {
|
|||||||
|
|
||||||
it('throws GitHubApiError on 422 unprocessable entity', async () => {
|
it('throws GitHubApiError on 422 unprocessable entity', async () => {
|
||||||
mockFetch(422, { message: 'Unprocessable Entity' });
|
mockFetch(422, { message: 'Unprocessable Entity' });
|
||||||
await expect(
|
await expect(fetchGitHubChangedFiles('owner', 'repo', 'bad-ref', 'v1.1.0')).rejects.toThrow(
|
||||||
fetchGitHubChangedFiles('owner', 'repo', 'bad-ref', 'v1.1.0')
|
GitHubApiError
|
||||||
).rejects.toThrow(GitHubApiError);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty array when files property is missing', async () => {
|
it('returns empty array when files property is missing', async () => {
|
||||||
@@ -141,9 +139,11 @@ describe('fetchGitHubChangedFiles', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sends Authorization header when token is provided', async () => {
|
it('sends Authorization header when token is provided', async () => {
|
||||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
const fetchSpy = vi
|
||||||
new Response(JSON.stringify({ status: 'ahead', files: [] }), { status: 200 })
|
.spyOn(global, 'fetch')
|
||||||
);
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ status: 'ahead', files: [] }), { status: 200 })
|
||||||
|
);
|
||||||
await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0', 'my-token');
|
await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0', 'my-token');
|
||||||
const callArgs = fetchSpy.mock.calls[0];
|
const callArgs = fetchSpy.mock.calls[0];
|
||||||
const headers = (callArgs[1] as RequestInit).headers as Record<string, string>;
|
const headers = (callArgs[1] as RequestInit).headers as Record<string, string>;
|
||||||
@@ -151,9 +151,11 @@ describe('fetchGitHubChangedFiles', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not send Authorization header when no token provided', async () => {
|
it('does not send Authorization header when no token provided', async () => {
|
||||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
const fetchSpy = vi
|
||||||
new Response(JSON.stringify({ status: 'ahead', files: [] }), { status: 200 })
|
.spyOn(global, 'fetch')
|
||||||
);
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ status: 'ahead', files: [] }), { status: 200 })
|
||||||
|
);
|
||||||
await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0');
|
await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0');
|
||||||
const callArgs = fetchSpy.mock.calls[0];
|
const callArgs = fetchSpy.mock.calls[0];
|
||||||
const headers = (callArgs[1] as RequestInit).headers as Record<string, string>;
|
const headers = (callArgs[1] as RequestInit).headers as Record<string, string>;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { applySqlitePragmas } from './connection';
|
||||||
|
import { loadSqliteVec } from './sqlite-vec';
|
||||||
|
|
||||||
let _client: Database.Database | null = null;
|
let _client: Database.Database | null = null;
|
||||||
|
|
||||||
@@ -11,9 +13,8 @@ export function getClient(): Database.Database {
|
|||||||
if (!_client) {
|
if (!_client) {
|
||||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||||
_client = new Database(env.DATABASE_URL);
|
_client = new Database(env.DATABASE_URL);
|
||||||
_client.pragma('journal_mode = WAL');
|
applySqlitePragmas(_client);
|
||||||
_client.pragma('foreign_keys = ON');
|
loadSqliteVec(_client);
|
||||||
_client.pragma('busy_timeout = 5000');
|
|
||||||
}
|
}
|
||||||
return _client;
|
return _client;
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/lib/server/db/connection.ts
Normal file
14
src/lib/server/db/connection.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
export const SQLITE_BUSY_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
export function applySqlitePragmas(db: Database.Database): void {
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
db.pragma(`busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
||||||
|
db.pragma('synchronous = NORMAL');
|
||||||
|
db.pragma('cache_size = -65536');
|
||||||
|
db.pragma('temp_store = MEMORY');
|
||||||
|
db.pragma('mmap_size = 268435456');
|
||||||
|
db.pragma('wal_autocheckpoint = 1000');
|
||||||
|
}
|
||||||
@@ -5,20 +5,16 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
|
import { applySqlitePragmas } from './connection';
|
||||||
|
import { loadSqliteVec } from './sqlite-vec';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||||
|
|
||||||
const client = new Database(env.DATABASE_URL);
|
const client = new Database(env.DATABASE_URL);
|
||||||
|
|
||||||
// Enable WAL mode for better concurrent read performance.
|
applySqlitePragmas(client);
|
||||||
client.pragma('journal_mode = WAL');
|
loadSqliteVec(client);
|
||||||
// Enforce foreign key constraints.
|
|
||||||
client.pragma('foreign_keys = ON');
|
|
||||||
// Wait up to 5 s when the DB is locked instead of failing immediately.
|
|
||||||
// Prevents SQLITE_BUSY errors when the indexing pipeline holds the write lock
|
|
||||||
// and an HTTP request arrives simultaneously.
|
|
||||||
client.pragma('busy_timeout = 5000');
|
|
||||||
|
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE INDEX `idx_embeddings_profile` ON `snippet_embeddings` (`profile_id`,`snippet_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_documents_repo_version` ON `documents` (`repository_id`,`version_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_jobs_repo_status` ON `indexing_jobs` (`repository_id`,`status`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_repositories_state` ON `repositories` (`state`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_snippets_repo_version` ON `snippets` (`repository_id`,`version_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_snippets_repo_type` ON `snippets` (`repository_id`,`type`);
|
||||||
File diff suppressed because it is too large
Load Diff
883
src/lib/server/db/migrations/meta/0006_snapshot.json
Normal file
883
src/lib/server/db/migrations/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,883 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "b8998bda-f89b-41bc-b923-3f676d153c79",
|
||||||
|
"prevId": "c326dcbe-1771-4a90-a566-0ebd1eca47ec",
|
||||||
|
"tables": {
|
||||||
|
"documents": {
|
||||||
|
"name": "documents",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"repository_id": {
|
||||||
|
"name": "repository_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"version_id": {
|
||||||
|
"name": "version_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"file_path": {
|
||||||
|
"name": "file_path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_count": {
|
||||||
|
"name": "token_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"checksum": {
|
||||||
|
"name": "checksum",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"indexed_at": {
|
||||||
|
"name": "indexed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_documents_repo_version": {
|
||||||
|
"name": "idx_documents_repo_version",
|
||||||
|
"columns": ["repository_id", "version_id"],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"documents_repository_id_repositories_id_fk": {
|
||||||
|
"name": "documents_repository_id_repositories_id_fk",
|
||||||
|
"tableFrom": "documents",
|
||||||
|
"tableTo": "repositories",
|
||||||
|
"columnsFrom": ["repository_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"documents_version_id_repository_versions_id_fk": {
|
||||||
|
"name": "documents_version_id_repository_versions_id_fk",
|
||||||
|
"tableFrom": "documents",
|
||||||
|
"tableTo": "repository_versions",
|
||||||
|
"columnsFrom": ["version_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"embedding_profiles": {
|
||||||
|
"name": "embedding_profiles",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"provider_kind": {
|
||||||
|
"name": "provider_kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"is_default": {
|
||||||
|
"name": "is_default",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"name": "dimensions",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"indexing_jobs": {
|
||||||
|
"name": "indexing_jobs",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"repository_id": {
|
||||||
|
"name": "repository_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"version_id": {
|
||||||
|
"name": "version_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'queued'"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"name": "progress",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"total_files": {
|
||||||
|
"name": "total_files",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"processed_files": {
|
||||||
|
"name": "processed_files",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"stage": {
|
||||||
|
"name": "stage",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'queued'"
|
||||||
|
},
|
||||||
|
"stage_detail": {
|
||||||
|
"name": "stage_detail",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"name": "error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_jobs_repo_status": {
|
||||||
|
"name": "idx_jobs_repo_status",
|
||||||
|
"columns": ["repository_id", "status"],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"indexing_jobs_repository_id_repositories_id_fk": {
|
||||||
|
"name": "indexing_jobs_repository_id_repositories_id_fk",
|
||||||
|
"tableFrom": "indexing_jobs",
|
||||||
|
"tableTo": "repositories",
|
||||||
|
"columnsFrom": ["repository_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"repositories": {
|
||||||
|
"name": "repositories",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source_url": {
|
||||||
|
"name": "source_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"branch": {
|
||||||
|
"name": "branch",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'main'"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'pending'"
|
||||||
|
},
|
||||||
|
"total_snippets": {
|
||||||
|
"name": "total_snippets",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"total_tokens": {
|
||||||
|
"name": "total_tokens",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"trust_score": {
|
||||||
|
"name": "trust_score",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"benchmark_score": {
|
||||||
|
"name": "benchmark_score",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"stars": {
|
||||||
|
"name": "stars",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"github_token": {
|
||||||
|
"name": "github_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_indexed_at": {
|
||||||
|
"name": "last_indexed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_repositories_state": {
|
||||||
|
"name": "idx_repositories_state",
|
||||||
|
"columns": ["state"],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"repository_configs": {
|
||||||
|
"name": "repository_configs",
|
||||||
|
"columns": {
|
||||||
|
"repository_id": {
|
||||||
|
"name": "repository_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"version_id": {
|
||||||
|
"name": "version_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"project_title": {
|
||||||
|
"name": "project_title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"name": "folders",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"exclude_folders": {
|
||||||
|
"name": "exclude_folders",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"exclude_files": {
|
||||||
|
"name": "exclude_files",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"name": "rules",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"previous_versions": {
|
||||||
|
"name": "previous_versions",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"uniq_repo_config_base": {
|
||||||
|
"name": "uniq_repo_config_base",
|
||||||
|
"columns": ["repository_id"],
|
||||||
|
"isUnique": true,
|
||||||
|
"where": "\"repository_configs\".\"version_id\" IS NULL"
|
||||||
|
},
|
||||||
|
"uniq_repo_config_version": {
|
||||||
|
"name": "uniq_repo_config_version",
|
||||||
|
"columns": ["repository_id", "version_id"],
|
||||||
|
"isUnique": true,
|
||||||
|
"where": "\"repository_configs\".\"version_id\" IS NOT NULL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"repository_configs_repository_id_repositories_id_fk": {
|
||||||
|
"name": "repository_configs_repository_id_repositories_id_fk",
|
||||||
|
"tableFrom": "repository_configs",
|
||||||
|
"tableTo": "repositories",
|
||||||
|
"columnsFrom": ["repository_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"repository_versions": {
|
||||||
|
"name": "repository_versions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"repository_id": {
|
||||||
|
"name": "repository_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"name": "tag",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"commit_hash": {
|
||||||
|
"name": "commit_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'pending'"
|
||||||
|
},
|
||||||
|
"total_snippets": {
|
||||||
|
"name": "total_snippets",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"indexed_at": {
|
||||||
|
"name": "indexed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"repository_versions_repository_id_repositories_id_fk": {
|
||||||
|
"name": "repository_versions_repository_id_repositories_id_fk",
|
||||||
|
"tableFrom": "repository_versions",
|
||||||
|
"tableTo": "repositories",
|
||||||
|
"columnsFrom": ["repository_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"snippet_embeddings": {
|
||||||
|
"name": "snippet_embeddings",
|
||||||
|
"columns": {
|
||||||
|
"snippet_id": {
|
||||||
|
"name": "snippet_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"profile_id": {
|
||||||
|
"name": "profile_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"name": "dimensions",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"embedding": {
|
||||||
|
"name": "embedding",
|
||||||
|
"type": "blob",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_embeddings_profile": {
|
||||||
|
"name": "idx_embeddings_profile",
|
||||||
|
"columns": ["profile_id", "snippet_id"],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"snippet_embeddings_snippet_id_snippets_id_fk": {
|
||||||
|
"name": "snippet_embeddings_snippet_id_snippets_id_fk",
|
||||||
|
"tableFrom": "snippet_embeddings",
|
||||||
|
"tableTo": "snippets",
|
||||||
|
"columnsFrom": ["snippet_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"snippet_embeddings_profile_id_embedding_profiles_id_fk": {
|
||||||
|
"name": "snippet_embeddings_profile_id_embedding_profiles_id_fk",
|
||||||
|
"tableFrom": "snippet_embeddings",
|
||||||
|
"tableTo": "embedding_profiles",
|
||||||
|
"columnsFrom": ["profile_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"snippet_embeddings_snippet_id_profile_id_pk": {
|
||||||
|
"columns": ["snippet_id", "profile_id"],
|
||||||
|
"name": "snippet_embeddings_snippet_id_profile_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"snippets": {
|
||||||
|
"name": "snippets",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"document_id": {
|
||||||
|
"name": "document_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"repository_id": {
|
||||||
|
"name": "repository_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"version_id": {
|
||||||
|
"name": "version_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"breadcrumb": {
|
||||||
|
"name": "breadcrumb",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_count": {
|
||||||
|
"name": "token_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_snippets_repo_version": {
|
||||||
|
"name": "idx_snippets_repo_version",
|
||||||
|
"columns": ["repository_id", "version_id"],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"idx_snippets_repo_type": {
|
||||||
|
"name": "idx_snippets_repo_type",
|
||||||
|
"columns": ["repository_id", "type"],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"snippets_document_id_documents_id_fk": {
|
||||||
|
"name": "snippets_document_id_documents_id_fk",
|
||||||
|
"tableFrom": "snippets",
|
||||||
|
"tableTo": "documents",
|
||||||
|
"columnsFrom": ["document_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"snippets_repository_id_repositories_id_fk": {
|
||||||
|
"name": "snippets_repository_id_repositories_id_fk",
|
||||||
|
"tableFrom": "snippets",
|
||||||
|
"tableTo": "repositories",
|
||||||
|
"columnsFrom": ["repository_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"snippets_version_id_repository_versions_id_fk": {
|
||||||
|
"name": "snippets_version_id_repository_versions_id_fk",
|
||||||
|
"tableFrom": "snippets",
|
||||||
|
"tableTo": "repository_versions",
|
||||||
|
"columnsFrom": ["version_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,55 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1774196053634,
|
"when": 1774196053634,
|
||||||
"tag": "0000_large_master_chief",
|
"tag": "0000_large_master_chief",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1774448049161,
|
"when": 1774448049161,
|
||||||
"tag": "0001_quick_nighthawk",
|
"tag": "0001_quick_nighthawk",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1774461897742,
|
"when": 1774461897742,
|
||||||
"tag": "0002_silky_stellaris",
|
"tag": "0002_silky_stellaris",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1743155877000,
|
"when": 1743155877000,
|
||||||
"tag": "0003_multiversion_config",
|
"tag": "0003_multiversion_config",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 4,
|
"idx": 4,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1774880275833,
|
"when": 1774880275833,
|
||||||
"tag": "0004_complete_sentry",
|
"tag": "0004_complete_sentry",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 5,
|
"idx": 5,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1774890536284,
|
"when": 1774890536284,
|
||||||
"tag": "0005_fix_stage_defaults",
|
"tag": "0005_fix_stage_defaults",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775038799913,
|
||||||
|
"tag": "0006_yielding_centennial",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
|
import { loadSqliteVec, sqliteVecRowidTableName, sqliteVecTableName } from './sqlite-vec';
|
||||||
import {
|
import {
|
||||||
repositories,
|
repositories,
|
||||||
repositoryVersions,
|
repositoryVersions,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
function createTestDb() {
|
function createTestDb() {
|
||||||
const client = new Database(':memory:');
|
const client = new Database(':memory:');
|
||||||
client.pragma('foreign_keys = ON');
|
client.pragma('foreign_keys = ON');
|
||||||
|
loadSqliteVec(client);
|
||||||
|
|
||||||
const db = drizzle(client, { schema });
|
const db = drizzle(client, { schema });
|
||||||
|
|
||||||
@@ -266,10 +268,11 @@ describe('snippets table', () => {
|
|||||||
|
|
||||||
describe('snippet_embeddings table', () => {
|
describe('snippet_embeddings table', () => {
|
||||||
let db: ReturnType<typeof createTestDb>['db'];
|
let db: ReturnType<typeof createTestDb>['db'];
|
||||||
|
let client: Database.Database;
|
||||||
let snippetId: string;
|
let snippetId: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
({ db } = createTestDb());
|
({ db, client } = createTestDb());
|
||||||
db.insert(repositories).values(makeRepo()).run();
|
db.insert(repositories).values(makeRepo()).run();
|
||||||
const docId = crypto.randomUUID();
|
const docId = crypto.randomUUID();
|
||||||
db.insert(documents)
|
db.insert(documents)
|
||||||
@@ -344,6 +347,30 @@ describe('snippet_embeddings table', () => {
|
|||||||
const result = db.select().from(snippetEmbeddings).all();
|
const result = db.select().from(snippetEmbeddings).all();
|
||||||
expect(result).toHaveLength(0);
|
expect(result).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the relational schema free of vec_embedding and retains the profile index', () => {
|
||||||
|
const columns = client.prepare("PRAGMA table_info('snippet_embeddings')").all() as Array<{
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
expect(columns.map((column) => column.name)).not.toContain('vec_embedding');
|
||||||
|
|
||||||
|
const indexes = client.prepare("PRAGMA index_list('snippet_embeddings')").all() as Array<{
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
expect(indexes.map((index) => index.name)).toContain('idx_embeddings_profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads sqlite-vec idempotently and derives deterministic per-profile table names', () => {
|
||||||
|
expect(() => loadSqliteVec(client)).not.toThrow();
|
||||||
|
const tableName = sqliteVecTableName('local-default');
|
||||||
|
const rowidTableName = sqliteVecRowidTableName('local-default');
|
||||||
|
|
||||||
|
expect(tableName).toMatch(/^snippet_embeddings_vec_local_default_[0-9a-f]{8}$/);
|
||||||
|
expect(rowidTableName).toMatch(/^snippet_embeddings_vec_rowids_local_default_[0-9a-f]{8}$/);
|
||||||
|
expect(sqliteVecTableName('local-default')).toBe(tableName);
|
||||||
|
expect(sqliteVecRowidTableName('local-default')).toBe(rowidTableName);
|
||||||
|
expect(sqliteVecTableName('local-default')).not.toBe(sqliteVecTableName('openai/custom'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('indexing_jobs table', () => {
|
describe('indexing_jobs table', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
blob,
|
blob,
|
||||||
|
index,
|
||||||
integer,
|
integer,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
real,
|
real,
|
||||||
@@ -12,29 +13,33 @@ import {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// repositories
|
// repositories
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const repositories = sqliteTable('repositories', {
|
export const repositories = sqliteTable(
|
||||||
id: text('id').primaryKey(), // e.g. "/facebook/react" or "/local/my-sdk"
|
'repositories',
|
||||||
title: text('title').notNull(),
|
{
|
||||||
description: text('description'),
|
id: text('id').primaryKey(), // e.g. "/facebook/react" or "/local/my-sdk"
|
||||||
source: text('source', { enum: ['github', 'local'] }).notNull(),
|
title: text('title').notNull(),
|
||||||
sourceUrl: text('source_url').notNull(), // GitHub URL or absolute local path
|
description: text('description'),
|
||||||
branch: text('branch').default('main'),
|
source: text('source', { enum: ['github', 'local'] }).notNull(),
|
||||||
state: text('state', {
|
sourceUrl: text('source_url').notNull(), // GitHub URL or absolute local path
|
||||||
enum: ['pending', 'indexing', 'indexed', 'error']
|
branch: text('branch').default('main'),
|
||||||
})
|
state: text('state', {
|
||||||
.notNull()
|
enum: ['pending', 'indexing', 'indexed', 'error']
|
||||||
.default('pending'),
|
})
|
||||||
totalSnippets: integer('total_snippets').default(0),
|
.notNull()
|
||||||
totalTokens: integer('total_tokens').default(0),
|
.default('pending'),
|
||||||
trustScore: real('trust_score').default(0), // 0.0–10.0
|
totalSnippets: integer('total_snippets').default(0),
|
||||||
benchmarkScore: real('benchmark_score').default(0), // 0.0–100.0; reserved for future quality metrics
|
totalTokens: integer('total_tokens').default(0),
|
||||||
stars: integer('stars'),
|
trustScore: real('trust_score').default(0), // 0.0–10.0
|
||||||
// TODO: encrypt at rest in production; stored as plaintext for v1
|
benchmarkScore: real('benchmark_score').default(0), // 0.0–100.0; reserved for future quality metrics
|
||||||
githubToken: text('github_token'),
|
stars: integer('stars'),
|
||||||
lastIndexedAt: integer('last_indexed_at', { mode: 'timestamp' }),
|
// TODO: encrypt at rest in production; stored as plaintext for v1
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
githubToken: text('github_token'),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
|
lastIndexedAt: integer('last_indexed_at', { mode: 'timestamp' }),
|
||||||
});
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
|
||||||
|
},
|
||||||
|
(t) => [index('idx_repositories_state').on(t.state)]
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// repository_versions
|
// repository_versions
|
||||||
@@ -60,40 +65,51 @@ export const repositoryVersions = sqliteTable('repository_versions', {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// documents
|
// documents
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const documents = sqliteTable('documents', {
|
export const documents = sqliteTable(
|
||||||
id: text('id').primaryKey(), // UUID
|
'documents',
|
||||||
repositoryId: text('repository_id')
|
{
|
||||||
.notNull()
|
id: text('id').primaryKey(), // UUID
|
||||||
.references(() => repositories.id, { onDelete: 'cascade' }),
|
repositoryId: text('repository_id')
|
||||||
versionId: text('version_id').references(() => repositoryVersions.id, { onDelete: 'cascade' }),
|
.notNull()
|
||||||
filePath: text('file_path').notNull(), // relative path within repo
|
.references(() => repositories.id, { onDelete: 'cascade' }),
|
||||||
title: text('title'),
|
versionId: text('version_id').references(() => repositoryVersions.id, { onDelete: 'cascade' }),
|
||||||
language: text('language'), // e.g. "typescript", "markdown"
|
filePath: text('file_path').notNull(), // relative path within repo
|
||||||
tokenCount: integer('token_count').default(0),
|
title: text('title'),
|
||||||
checksum: text('checksum').notNull(), // SHA-256 of file content
|
language: text('language'), // e.g. "typescript", "markdown"
|
||||||
indexedAt: integer('indexed_at', { mode: 'timestamp' }).notNull()
|
tokenCount: integer('token_count').default(0),
|
||||||
});
|
checksum: text('checksum').notNull(), // SHA-256 of file content
|
||||||
|
indexedAt: integer('indexed_at', { mode: 'timestamp' }).notNull()
|
||||||
|
},
|
||||||
|
(t) => [index('idx_documents_repo_version').on(t.repositoryId, t.versionId)]
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// snippets
|
// snippets
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const snippets = sqliteTable('snippets', {
|
export const snippets = sqliteTable(
|
||||||
id: text('id').primaryKey(), // UUID
|
'snippets',
|
||||||
documentId: text('document_id')
|
{
|
||||||
.notNull()
|
id: text('id').primaryKey(), // UUID
|
||||||
.references(() => documents.id, { onDelete: 'cascade' }),
|
documentId: text('document_id')
|
||||||
repositoryId: text('repository_id')
|
.notNull()
|
||||||
.notNull()
|
.references(() => documents.id, { onDelete: 'cascade' }),
|
||||||
.references(() => repositories.id, { onDelete: 'cascade' }),
|
repositoryId: text('repository_id')
|
||||||
versionId: text('version_id').references(() => repositoryVersions.id, { onDelete: 'cascade' }),
|
.notNull()
|
||||||
type: text('type', { enum: ['code', 'info'] }).notNull(),
|
.references(() => repositories.id, { onDelete: 'cascade' }),
|
||||||
title: text('title'),
|
versionId: text('version_id').references(() => repositoryVersions.id, { onDelete: 'cascade' }),
|
||||||
content: text('content').notNull(), // searchable text / code
|
type: text('type', { enum: ['code', 'info'] }).notNull(),
|
||||||
language: text('language'),
|
title: text('title'),
|
||||||
breadcrumb: text('breadcrumb'), // e.g. "Installation > Getting Started"
|
content: text('content').notNull(), // searchable text / code
|
||||||
tokenCount: integer('token_count').default(0),
|
language: text('language'),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
breadcrumb: text('breadcrumb'), // e.g. "Installation > Getting Started"
|
||||||
});
|
tokenCount: integer('token_count').default(0),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('idx_snippets_repo_version').on(t.repositoryId, t.versionId),
|
||||||
|
index('idx_snippets_repo_type').on(t.repositoryId, t.type)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// embedding_profiles
|
// embedding_profiles
|
||||||
@@ -128,33 +144,54 @@ export const snippetEmbeddings = sqliteTable(
|
|||||||
embedding: blob('embedding').notNull(), // Float32Array as binary blob
|
embedding: blob('embedding').notNull(), // Float32Array as binary blob
|
||||||
createdAt: integer('created_at').notNull()
|
createdAt: integer('created_at').notNull()
|
||||||
},
|
},
|
||||||
(table) => [primaryKey({ columns: [table.snippetId, table.profileId] })]
|
(table) => [
|
||||||
|
primaryKey({ columns: [table.snippetId, table.profileId] }),
|
||||||
|
index('idx_embeddings_profile').on(table.profileId, table.snippetId)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// indexing_jobs
|
// indexing_jobs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const indexingJobs = sqliteTable('indexing_jobs', {
|
export const indexingJobs = sqliteTable(
|
||||||
id: text('id').primaryKey(), // UUID
|
'indexing_jobs',
|
||||||
repositoryId: text('repository_id')
|
{
|
||||||
.notNull()
|
id: text('id').primaryKey(), // UUID
|
||||||
.references(() => repositories.id, { onDelete: 'cascade' }),
|
repositoryId: text('repository_id')
|
||||||
versionId: text('version_id'),
|
.notNull()
|
||||||
status: text('status', {
|
.references(() => repositories.id, { onDelete: 'cascade' }),
|
||||||
enum: ['queued', 'running', 'paused', 'cancelled', 'done', 'failed']
|
versionId: text('version_id'),
|
||||||
})
|
status: text('status', {
|
||||||
.notNull()
|
enum: ['queued', 'running', 'paused', 'cancelled', 'done', 'failed']
|
||||||
.default('queued'),
|
})
|
||||||
progress: integer('progress').default(0), // 0–100
|
.notNull()
|
||||||
totalFiles: integer('total_files').default(0),
|
.default('queued'),
|
||||||
processedFiles: integer('processed_files').default(0),
|
progress: integer('progress').default(0), // 0–100
|
||||||
stage: text('stage', { enum: ['queued', 'differential', 'crawling', 'cloning', 'parsing', 'storing', 'embedding', 'done', 'failed'] }).notNull().default('queued'),
|
totalFiles: integer('total_files').default(0),
|
||||||
stageDetail: text('stage_detail'),
|
processedFiles: integer('processed_files').default(0),
|
||||||
error: text('error'),
|
stage: text('stage', {
|
||||||
startedAt: integer('started_at', { mode: 'timestamp' }),
|
enum: [
|
||||||
completedAt: integer('completed_at', { mode: 'timestamp' }),
|
'queued',
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
'differential',
|
||||||
});
|
'crawling',
|
||||||
|
'cloning',
|
||||||
|
'parsing',
|
||||||
|
'storing',
|
||||||
|
'embedding',
|
||||||
|
'done',
|
||||||
|
'failed'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.default('queued'),
|
||||||
|
stageDetail: text('stage_detail'),
|
||||||
|
error: text('error'),
|
||||||
|
startedAt: integer('started_at', { mode: 'timestamp' }),
|
||||||
|
completedAt: integer('completed_at', { mode: 'timestamp' }),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||||
|
},
|
||||||
|
(t) => [index('idx_jobs_repo_status').on(t.repositoryId, t.status)]
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// repository_configs
|
// repository_configs
|
||||||
|
|||||||
49
src/lib/server/db/sqlite-vec.ts
Normal file
49
src/lib/server/db/sqlite-vec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import * as sqliteVec from 'sqlite-vec';
|
||||||
|
|
||||||
|
const loadedConnections = new WeakSet<Database.Database>();
|
||||||
|
|
||||||
|
function stableHash(value: string): string {
|
||||||
|
let hash = 2166136261;
|
||||||
|
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash ^= value.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeIdentifierPart(value: string): string {
|
||||||
|
const sanitized = value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
|
||||||
|
return sanitized.length > 0 ? sanitized.slice(0, 32) : 'profile';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sqliteVecTableSuffix(profileId: string): string {
|
||||||
|
return `${sanitizeIdentifierPart(profileId)}_${stableHash(profileId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sqliteVecTableName(profileId: string): string {
|
||||||
|
return `snippet_embeddings_vec_${sqliteVecTableSuffix(profileId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sqliteVecRowidTableName(profileId: string): string {
|
||||||
|
return `snippet_embeddings_vec_rowids_${sqliteVecTableSuffix(profileId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quoteSqliteIdentifier(identifier: string): string {
|
||||||
|
return `"${identifier.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSqliteVec(db: Database.Database): void {
|
||||||
|
if (loadedConnections.has(db)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqliteVec.load(db);
|
||||||
|
loadedConnections.add(db);
|
||||||
|
}
|
||||||
2
src/lib/server/db/vectors.sql
Normal file
2
src/lib/server/db/vectors.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Relational vec_embedding bootstrap removed in iteration 2.
|
||||||
|
-- Downstream sqlite-vec vec0 tables are created on demand in application code.
|
||||||
@@ -12,6 +12,8 @@ import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import * as schema from '../db/schema.js';
|
import * as schema from '../db/schema.js';
|
||||||
|
import { loadSqliteVec, sqliteVecRowidTableName, sqliteVecTableName } from '../db/sqlite-vec.js';
|
||||||
|
import { SqliteVecStore } from '../search/sqlite-vec.store.js';
|
||||||
|
|
||||||
import { NoopEmbeddingProvider, EmbeddingError, type EmbeddingVector } from './provider.js';
|
import { NoopEmbeddingProvider, EmbeddingError, type EmbeddingVector } from './provider.js';
|
||||||
import { OpenAIEmbeddingProvider } from './openai.provider.js';
|
import { OpenAIEmbeddingProvider } from './openai.provider.js';
|
||||||
@@ -31,6 +33,7 @@ import { createProviderFromProfile } from './registry.js';
|
|||||||
function createTestDb() {
|
function createTestDb() {
|
||||||
const client = new Database(':memory:');
|
const client = new Database(':memory:');
|
||||||
client.pragma('foreign_keys = ON');
|
client.pragma('foreign_keys = ON');
|
||||||
|
loadSqliteVec(client);
|
||||||
|
|
||||||
const db = drizzle(client, { schema });
|
const db = drizzle(client, { schema });
|
||||||
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
||||||
@@ -387,10 +390,19 @@ describe('EmbeddingService', () => {
|
|||||||
embedding: Buffer;
|
embedding: Buffer;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
};
|
};
|
||||||
|
expect((row as Record<string, unknown>).vec_embedding).toBeUndefined();
|
||||||
expect(row.model).toBe('test-model');
|
expect(row.model).toBe('test-model');
|
||||||
expect(row.dimensions).toBe(4);
|
expect(row.dimensions).toBe(4);
|
||||||
expect(row.profile_id).toBe('local-default');
|
expect(row.profile_id).toBe('local-default');
|
||||||
expect(row.embedding).toBeInstanceOf(Buffer);
|
expect(row.embedding).toBeInstanceOf(Buffer);
|
||||||
|
|
||||||
|
const queryEmbedding = service.getEmbedding(snippetId, 'local-default');
|
||||||
|
const matches = new SqliteVecStore(client).queryNearestNeighbors(queryEmbedding!, {
|
||||||
|
repositoryId: '/test/embed-repo',
|
||||||
|
profileId: 'local-default',
|
||||||
|
limit: 5
|
||||||
|
});
|
||||||
|
expect(matches[0]?.snippetId).toBe(snippetId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores embeddings as retrievable Float32Array blobs', async () => {
|
it('stores embeddings as retrievable Float32Array blobs', async () => {
|
||||||
@@ -408,6 +420,25 @@ describe('EmbeddingService', () => {
|
|||||||
expect(embedding![2]).toBeCloseTo(0.2, 5);
|
expect(embedding![2]).toBeCloseTo(0.2, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can delegate embedding persistence to an injected writer', async () => {
|
||||||
|
const snippetId = seedSnippet(db, client);
|
||||||
|
const provider = makeProvider(4);
|
||||||
|
const persistEmbeddings = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const service = new EmbeddingService(client, provider, 'local-default', {
|
||||||
|
persistEmbeddings
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.embedSnippets([snippetId]);
|
||||||
|
|
||||||
|
expect(persistEmbeddings).toHaveBeenCalledTimes(1);
|
||||||
|
const rows = client
|
||||||
|
.prepare(
|
||||||
|
'SELECT COUNT(*) AS cnt FROM snippet_embeddings WHERE snippet_id = ? AND profile_id = ?'
|
||||||
|
)
|
||||||
|
.get(snippetId, 'local-default') as { cnt: number };
|
||||||
|
expect(rows.cnt).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('stores embeddings under the configured profile ID', async () => {
|
it('stores embeddings under the configured profile ID', async () => {
|
||||||
client
|
client
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -415,16 +446,7 @@ describe('EmbeddingService', () => {
|
|||||||
(id, provider_kind, title, enabled, is_default, model, dimensions, config, created_at, updated_at)
|
(id, provider_kind, title, enabled, is_default, model, dimensions, config, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, unixepoch(), unixepoch())`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, unixepoch(), unixepoch())`
|
||||||
)
|
)
|
||||||
.run(
|
.run('openai-custom', 'openai-compatible', 'OpenAI Custom', 1, 0, 'test-model', 4, '{}');
|
||||||
'openai-custom',
|
|
||||||
'openai-compatible',
|
|
||||||
'OpenAI Custom',
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
'test-model',
|
|
||||||
4,
|
|
||||||
'{}'
|
|
||||||
);
|
|
||||||
|
|
||||||
const snippetId = seedSnippet(db, client);
|
const snippetId = seedSnippet(db, client);
|
||||||
const provider = makeProvider(4, 'test-model');
|
const provider = makeProvider(4, 'test-model');
|
||||||
@@ -436,6 +458,22 @@ describe('EmbeddingService', () => {
|
|||||||
.prepare('SELECT profile_id FROM snippet_embeddings WHERE snippet_id = ?')
|
.prepare('SELECT profile_id FROM snippet_embeddings WHERE snippet_id = ?')
|
||||||
.get(snippetId) as { profile_id: string };
|
.get(snippetId) as { profile_id: string };
|
||||||
expect(row.profile_id).toBe('openai-custom');
|
expect(row.profile_id).toBe('openai-custom');
|
||||||
|
|
||||||
|
const queryEmbedding = service.getEmbedding(snippetId, 'openai-custom');
|
||||||
|
const store = new SqliteVecStore(client);
|
||||||
|
const customMatches = store.queryNearestNeighbors(queryEmbedding!, {
|
||||||
|
repositoryId: '/test/embed-repo',
|
||||||
|
profileId: 'openai-custom',
|
||||||
|
limit: 5
|
||||||
|
});
|
||||||
|
const defaultMatches = store.queryNearestNeighbors(new Float32Array([1, 0, 0, 0]), {
|
||||||
|
repositoryId: '/test/embed-repo',
|
||||||
|
profileId: 'local-default',
|
||||||
|
limit: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(customMatches[0]?.snippetId).toBe(snippetId);
|
||||||
|
expect(defaultMatches).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is idempotent — re-embedding replaces the existing row', async () => {
|
it('is idempotent — re-embedding replaces the existing row', async () => {
|
||||||
@@ -450,6 +488,17 @@ describe('EmbeddingService', () => {
|
|||||||
.prepare('SELECT COUNT(*) as cnt FROM snippet_embeddings WHERE snippet_id = ?')
|
.prepare('SELECT COUNT(*) as cnt FROM snippet_embeddings WHERE snippet_id = ?')
|
||||||
.get(snippetId) as { cnt: number };
|
.get(snippetId) as { cnt: number };
|
||||||
expect(rows.cnt).toBe(1);
|
expect(rows.cnt).toBe(1);
|
||||||
|
|
||||||
|
const vecTable = sqliteVecTableName('local-default');
|
||||||
|
const rowidTable = sqliteVecRowidTableName('local-default');
|
||||||
|
const vecRows = client.prepare(`SELECT COUNT(*) as cnt FROM "${vecTable}"`).get() as {
|
||||||
|
cnt: number;
|
||||||
|
};
|
||||||
|
const rowidRows = client.prepare(`SELECT COUNT(*) as cnt FROM "${rowidTable}"`).get() as {
|
||||||
|
cnt: number;
|
||||||
|
};
|
||||||
|
expect(vecRows.cnt).toBe(1);
|
||||||
|
expect(rowidRows.cnt).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onProgress after each batch', async () => {
|
it('calls onProgress after each batch', async () => {
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
|
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
import type { EmbeddingProvider } from './provider.js';
|
import type { EmbeddingProvider } from './provider.js';
|
||||||
|
import { SqliteVecStore } from '$lib/server/search/sqlite-vec.store.js';
|
||||||
|
import {
|
||||||
|
upsertEmbeddings,
|
||||||
|
type PersistedEmbedding
|
||||||
|
} from '$lib/server/pipeline/write-operations.js';
|
||||||
|
|
||||||
interface SnippetRow {
|
interface SnippetRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,11 +22,18 @@ const BATCH_SIZE = 50;
|
|||||||
const TEXT_MAX_CHARS = 2048;
|
const TEXT_MAX_CHARS = 2048;
|
||||||
|
|
||||||
export class EmbeddingService {
|
export class EmbeddingService {
|
||||||
|
private readonly sqliteVecStore: SqliteVecStore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly db: Database.Database,
|
private readonly db: Database.Database,
|
||||||
private readonly provider: EmbeddingProvider,
|
private readonly provider: EmbeddingProvider,
|
||||||
private readonly profileId: string = 'local-default'
|
private readonly profileId: string = 'local-default',
|
||||||
) {}
|
private readonly persistenceDelegate?: {
|
||||||
|
persistEmbeddings?: (embeddings: PersistedEmbedding[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
this.sqliteVecStore = new SqliteVecStore(db);
|
||||||
|
}
|
||||||
|
|
||||||
findSnippetIdsMissingEmbeddings(repositoryId: string, versionId: string | null): string[] {
|
findSnippetIdsMissingEmbeddings(repositoryId: string, versionId: string | null): string[] {
|
||||||
if (versionId) {
|
if (versionId) {
|
||||||
@@ -89,31 +101,31 @@ export class EmbeddingService {
|
|||||||
[s.title, s.breadcrumb, s.content].filter(Boolean).join('\n').slice(0, TEXT_MAX_CHARS)
|
[s.title, s.breadcrumb, s.content].filter(Boolean).join('\n').slice(0, TEXT_MAX_CHARS)
|
||||||
);
|
);
|
||||||
|
|
||||||
const insert = this.db.prepare<[string, string, string, number, Buffer]>(`
|
|
||||||
INSERT OR REPLACE INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, unixepoch())
|
|
||||||
`);
|
|
||||||
|
|
||||||
for (let i = 0; i < snippets.length; i += BATCH_SIZE) {
|
for (let i = 0; i < snippets.length; i += BATCH_SIZE) {
|
||||||
const batchSnippets = snippets.slice(i, i + BATCH_SIZE);
|
const batchSnippets = snippets.slice(i, i + BATCH_SIZE);
|
||||||
const batchTexts = texts.slice(i, i + BATCH_SIZE);
|
const batchTexts = texts.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
const embeddings = await this.provider.embed(batchTexts);
|
const embeddings = await this.provider.embed(batchTexts);
|
||||||
|
const persistedEmbeddings: PersistedEmbedding[] = batchSnippets.map((snippet, index) => {
|
||||||
const insertMany = this.db.transaction(() => {
|
const embedding = embeddings[index];
|
||||||
for (let j = 0; j < batchSnippets.length; j++) {
|
return {
|
||||||
const snippet = batchSnippets[j];
|
snippetId: snippet.id,
|
||||||
const embedding = embeddings[j];
|
profileId: this.profileId,
|
||||||
insert.run(
|
model: embedding.model,
|
||||||
snippet.id,
|
dimensions: embedding.dimensions,
|
||||||
this.profileId,
|
embedding: Buffer.from(
|
||||||
embedding.model,
|
embedding.values.buffer,
|
||||||
embedding.dimensions,
|
embedding.values.byteOffset,
|
||||||
Buffer.from(embedding.values.buffer)
|
embedding.values.byteLength
|
||||||
);
|
)
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
insertMany();
|
|
||||||
|
if (this.persistenceDelegate?.persistEmbeddings) {
|
||||||
|
await this.persistenceDelegate.persistEmbeddings(persistedEmbeddings);
|
||||||
|
} else {
|
||||||
|
upsertEmbeddings(this.db, persistedEmbeddings);
|
||||||
|
}
|
||||||
|
|
||||||
onProgress?.(Math.min(i + BATCH_SIZE, snippets.length), snippets.length);
|
onProgress?.(Math.min(i + BATCH_SIZE, snippets.length), snippets.length);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { EmbeddingProfile, EmbeddingProfileEntity } from '$lib/server/models/embedding-profile.js';
|
||||||
EmbeddingProfile,
|
|
||||||
EmbeddingProfileEntity
|
|
||||||
} from '$lib/server/models/embedding-profile.js';
|
|
||||||
|
|
||||||
function parseConfig(config: Record<string, unknown> | string | null): Record<string, unknown> {
|
function parseConfig(config: Record<string, unknown> | string | null): Record<string, unknown> {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ function createTestDb(): Database.Database {
|
|||||||
'0004_complete_sentry.sql'
|
'0004_complete_sentry.sql'
|
||||||
]) {
|
]) {
|
||||||
const sql = readFileSync(join(migrationsFolder, migrationFile), 'utf-8');
|
const sql = readFileSync(join(migrationsFolder, migrationFile), 'utf-8');
|
||||||
for (const stmt of sql.split('--> statement-breakpoint').map((s) => s.trim()).filter(Boolean)) {
|
for (const stmt of sql
|
||||||
|
.split('--> statement-breakpoint')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)) {
|
||||||
client.exec(stmt);
|
client.exec(stmt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,9 +116,10 @@ function insertDocument(db: Database.Database, versionId: string, filePath: stri
|
|||||||
.run(
|
.run(
|
||||||
id,
|
id,
|
||||||
db
|
db
|
||||||
.prepare<[string], { repository_id: string }>(
|
.prepare<
|
||||||
`SELECT repository_id FROM repository_versions WHERE id = ?`
|
[string],
|
||||||
)
|
{ repository_id: string }
|
||||||
|
>(`SELECT repository_id FROM repository_versions WHERE id = ?`)
|
||||||
.get(versionId)?.repository_id ?? '/test/repo',
|
.get(versionId)?.repository_id ?? '/test/repo',
|
||||||
versionId,
|
versionId,
|
||||||
filePath,
|
filePath,
|
||||||
@@ -280,9 +284,9 @@ describe('buildDifferentialPlan', () => {
|
|||||||
insertDocument(db, v1Id, 'packages/react/index.js');
|
insertDocument(db, v1Id, 'packages/react/index.js');
|
||||||
insertDocument(db, v1Id, 'packages/react-dom/index.js');
|
insertDocument(db, v1Id, 'packages/react-dom/index.js');
|
||||||
|
|
||||||
const fetchFn = vi.fn().mockResolvedValue([
|
const fetchFn = vi
|
||||||
{ path: 'packages/react/index.js', status: 'modified' as const }
|
.fn()
|
||||||
]);
|
.mockResolvedValue([{ path: 'packages/react/index.js', status: 'modified' as const }]);
|
||||||
|
|
||||||
const plan = await buildDifferentialPlan({
|
const plan = await buildDifferentialPlan({
|
||||||
repo,
|
repo,
|
||||||
@@ -292,13 +296,7 @@ describe('buildDifferentialPlan', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchFn).toHaveBeenCalledOnce();
|
expect(fetchFn).toHaveBeenCalledOnce();
|
||||||
expect(fetchFn).toHaveBeenCalledWith(
|
expect(fetchFn).toHaveBeenCalledWith('facebook', 'react', 'v18.0.0', 'v18.1.0', 'ghp_test123');
|
||||||
'facebook',
|
|
||||||
'react',
|
|
||||||
'v18.0.0',
|
|
||||||
'v18.1.0',
|
|
||||||
'ghp_test123'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(plan).not.toBeNull();
|
expect(plan).not.toBeNull();
|
||||||
expect(plan!.changedPaths.has('packages/react/index.js')).toBe(true);
|
expect(plan!.changedPaths.has('packages/react/index.js')).toBe(true);
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ export async function buildDifferentialPlan(params: {
|
|||||||
try {
|
try {
|
||||||
// 1. Load all indexed versions for this repository
|
// 1. Load all indexed versions for this repository
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(`SELECT * FROM repository_versions WHERE repository_id = ? AND state = 'indexed'`)
|
||||||
`SELECT * FROM repository_versions WHERE repository_id = ? AND state = 'indexed'`
|
|
||||||
)
|
|
||||||
.all(repo.id) as RepositoryVersionEntity[];
|
.all(repo.id) as RepositoryVersionEntity[];
|
||||||
|
|
||||||
const indexedVersions: RepositoryVersion[] = rows.map((row) =>
|
const indexedVersions: RepositoryVersion[] = rows.map((row) =>
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { workerData, parentPort } from 'node:worker_threads';
|
import { workerData, parentPort } from 'node:worker_threads';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { EmbeddingService } from '$lib/server/embeddings/embedding.service.js';
|
import { EmbeddingService } from '$lib/server/embeddings/embedding.service.js';
|
||||||
|
import { applySqlitePragmas } from '$lib/server/db/connection.js';
|
||||||
import { createProviderFromProfile } from '$lib/server/embeddings/registry.js';
|
import { createProviderFromProfile } from '$lib/server/embeddings/registry.js';
|
||||||
import { EmbeddingProfileMapper } from '$lib/server/mappers/embedding-profile.mapper.js';
|
import { EmbeddingProfileMapper } from '$lib/server/mappers/embedding-profile.mapper.js';
|
||||||
import { EmbeddingProfileEntity, type EmbeddingProfileEntityProps } from '$lib/server/models/embedding-profile.js';
|
import {
|
||||||
import type { EmbedWorkerRequest, EmbedWorkerResponse, WorkerInitData } from './worker-types.js';
|
EmbeddingProfileEntity,
|
||||||
|
type EmbeddingProfileEntityProps
|
||||||
|
} from '$lib/server/models/embedding-profile.js';
|
||||||
|
import type {
|
||||||
|
EmbedWorkerRequest,
|
||||||
|
EmbedWorkerResponse,
|
||||||
|
SerializedEmbedding,
|
||||||
|
WorkerInitData
|
||||||
|
} from './worker-types.js';
|
||||||
|
|
||||||
const { dbPath, embeddingProfileId } = workerData as WorkerInitData;
|
const { dbPath, embeddingProfileId } = workerData as WorkerInitData;
|
||||||
|
|
||||||
@@ -18,12 +27,12 @@ if (!embeddingProfileId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
db.pragma('journal_mode = WAL');
|
applySqlitePragmas(db);
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
db.pragma('busy_timeout = 5000');
|
|
||||||
|
|
||||||
// Load the embedding profile from DB
|
// Load the embedding profile from DB
|
||||||
const rawProfile = db.prepare('SELECT * FROM embedding_profiles WHERE id = ?').get(embeddingProfileId);
|
const rawProfile = db
|
||||||
|
.prepare('SELECT * FROM embedding_profiles WHERE id = ?')
|
||||||
|
.get(embeddingProfileId);
|
||||||
|
|
||||||
if (!rawProfile) {
|
if (!rawProfile) {
|
||||||
db.close();
|
db.close();
|
||||||
@@ -38,9 +47,55 @@ if (!rawProfile) {
|
|||||||
const profileEntity = new EmbeddingProfileEntity(rawProfile as EmbeddingProfileEntityProps);
|
const profileEntity = new EmbeddingProfileEntity(rawProfile as EmbeddingProfileEntityProps);
|
||||||
const profile = EmbeddingProfileMapper.fromEntity(profileEntity);
|
const profile = EmbeddingProfileMapper.fromEntity(profileEntity);
|
||||||
|
|
||||||
|
let pendingWrite: {
|
||||||
|
jobId: string;
|
||||||
|
resolve: () => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
} | null = null;
|
||||||
|
let currentJobId: string | null = null;
|
||||||
|
|
||||||
|
function requestWrite(
|
||||||
|
message: Extract<EmbedWorkerResponse, { type: 'write_embeddings' }>
|
||||||
|
): Promise<void> {
|
||||||
|
if (pendingWrite) {
|
||||||
|
return Promise.reject(new Error(`write request already in flight for ${pendingWrite.jobId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingWrite = {
|
||||||
|
jobId: message.jobId,
|
||||||
|
resolve: () => {
|
||||||
|
pendingWrite = null;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
reject: (error: Error) => {
|
||||||
|
pendingWrite = null;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
parentPort!.postMessage(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create provider and embedding service
|
// Create provider and embedding service
|
||||||
const provider = createProviderFromProfile(profile);
|
const provider = createProviderFromProfile(profile);
|
||||||
const embeddingService = new EmbeddingService(db, provider, embeddingProfileId);
|
const embeddingService = new EmbeddingService(db, provider, embeddingProfileId, {
|
||||||
|
persistEmbeddings: async (embeddings) => {
|
||||||
|
const serializedEmbeddings: SerializedEmbedding[] = embeddings.map((item) => ({
|
||||||
|
snippetId: item.snippetId,
|
||||||
|
profileId: item.profileId,
|
||||||
|
model: item.model,
|
||||||
|
dimensions: item.dimensions,
|
||||||
|
embedding: Uint8Array.from(item.embedding)
|
||||||
|
}));
|
||||||
|
|
||||||
|
await requestWrite({
|
||||||
|
type: 'write_embeddings',
|
||||||
|
jobId: currentJobId ?? 'unknown',
|
||||||
|
embeddings: serializedEmbeddings
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Signal ready after service initialization
|
// Signal ready after service initialization
|
||||||
parentPort!.postMessage({
|
parentPort!.postMessage({
|
||||||
@@ -48,12 +103,27 @@ parentPort!.postMessage({
|
|||||||
} satisfies EmbedWorkerResponse);
|
} satisfies EmbedWorkerResponse);
|
||||||
|
|
||||||
parentPort!.on('message', async (msg: EmbedWorkerRequest) => {
|
parentPort!.on('message', async (msg: EmbedWorkerRequest) => {
|
||||||
|
if (msg.type === 'write_ack') {
|
||||||
|
if (pendingWrite?.jobId === msg.jobId) {
|
||||||
|
pendingWrite.resolve();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'write_error') {
|
||||||
|
if (pendingWrite?.jobId === msg.jobId) {
|
||||||
|
pendingWrite.reject(new Error(msg.error));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'shutdown') {
|
if (msg.type === 'shutdown') {
|
||||||
db.close();
|
db.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'embed') {
|
if (msg.type === 'embed') {
|
||||||
|
currentJobId = msg.jobId;
|
||||||
try {
|
try {
|
||||||
const snippetIds = embeddingService.findSnippetIdsMissingEmbeddings(
|
const snippetIds = embeddingService.findSnippetIdsMissingEmbeddings(
|
||||||
msg.repositoryId,
|
msg.repositoryId,
|
||||||
@@ -79,6 +149,8 @@ parentPort!.on('message', async (msg: EmbedWorkerRequest) => {
|
|||||||
jobId: msg.jobId,
|
jobId: msg.jobId,
|
||||||
error: err instanceof Error ? err.message : String(err)
|
error: err instanceof Error ? err.message : String(err)
|
||||||
} satisfies EmbedWorkerResponse);
|
} satisfies EmbedWorkerResponse);
|
||||||
|
} finally {
|
||||||
|
currentJobId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { JobQueue } from './job-queue.js';
|
|||||||
import { IndexingPipeline } from './indexing.pipeline.js';
|
import { IndexingPipeline } from './indexing.pipeline.js';
|
||||||
import { recoverStaleJobs } from './startup.js';
|
import { recoverStaleJobs } from './startup.js';
|
||||||
import { EmbeddingService } from '$lib/server/embeddings/embedding.service.js';
|
import { EmbeddingService } from '$lib/server/embeddings/embedding.service.js';
|
||||||
|
import { loadSqliteVec } from '$lib/server/db/sqlite-vec.js';
|
||||||
|
import { SqliteVecStore } from '$lib/server/search/sqlite-vec.store.js';
|
||||||
|
import { sqliteVecRowidTableName, sqliteVecTableName } from '$lib/server/db/sqlite-vec.js';
|
||||||
import * as diffStrategy from './differential-strategy.js';
|
import * as diffStrategy from './differential-strategy.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -22,6 +25,7 @@ import * as diffStrategy from './differential-strategy.js';
|
|||||||
function createTestDb(): Database.Database {
|
function createTestDb(): Database.Database {
|
||||||
const client = new Database(':memory:');
|
const client = new Database(':memory:');
|
||||||
client.pragma('foreign_keys = ON');
|
client.pragma('foreign_keys = ON');
|
||||||
|
loadSqliteVec(client);
|
||||||
|
|
||||||
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
||||||
for (const migrationFile of [
|
for (const migrationFile of [
|
||||||
@@ -29,7 +33,9 @@ function createTestDb(): Database.Database {
|
|||||||
'0001_quick_nighthawk.sql',
|
'0001_quick_nighthawk.sql',
|
||||||
'0002_silky_stellaris.sql',
|
'0002_silky_stellaris.sql',
|
||||||
'0003_multiversion_config.sql',
|
'0003_multiversion_config.sql',
|
||||||
'0004_complete_sentry.sql'
|
'0004_complete_sentry.sql',
|
||||||
|
'0005_fix_stage_defaults.sql',
|
||||||
|
'0006_yielding_centennial.sql'
|
||||||
]) {
|
]) {
|
||||||
const migrationSql = readFileSync(join(migrationsFolder, migrationFile), 'utf-8');
|
const migrationSql = readFileSync(join(migrationsFolder, migrationFile), 'utf-8');
|
||||||
|
|
||||||
@@ -460,12 +466,15 @@ describe('IndexingPipeline', () => {
|
|||||||
const job1 = makeJob();
|
const job1 = makeJob();
|
||||||
await pipeline.run(job1 as never);
|
await pipeline.run(job1 as never);
|
||||||
|
|
||||||
const firstSnippetIds = (db.prepare(`SELECT id FROM snippets ORDER BY id`).all() as { id: string }[])
|
const firstSnippetIds = (
|
||||||
.map((row) => row.id);
|
db.prepare(`SELECT id FROM snippets ORDER BY id`).all() as { id: string }[]
|
||||||
|
).map((row) => row.id);
|
||||||
expect(firstSnippetIds.length).toBeGreaterThan(0);
|
expect(firstSnippetIds.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
const firstEmbeddingCount = (
|
const firstEmbeddingCount = (
|
||||||
db.prepare(`SELECT COUNT(*) as n FROM snippet_embeddings WHERE profile_id = 'local-default'`).get() as {
|
db
|
||||||
|
.prepare(`SELECT COUNT(*) as n FROM snippet_embeddings WHERE profile_id = 'local-default'`)
|
||||||
|
.get() as {
|
||||||
n: number;
|
n: number;
|
||||||
}
|
}
|
||||||
).n;
|
).n;
|
||||||
@@ -477,11 +486,15 @@ describe('IndexingPipeline', () => {
|
|||||||
const job2 = db.prepare(`SELECT * FROM indexing_jobs WHERE id = ?`).get(job2Id) as never;
|
const job2 = db.prepare(`SELECT * FROM indexing_jobs WHERE id = ?`).get(job2Id) as never;
|
||||||
await pipeline.run(job2);
|
await pipeline.run(job2);
|
||||||
|
|
||||||
const secondSnippetIds = (db.prepare(`SELECT id FROM snippets ORDER BY id`).all() as {
|
const secondSnippetIds = (
|
||||||
id: string;
|
db.prepare(`SELECT id FROM snippets ORDER BY id`).all() as {
|
||||||
}[]).map((row) => row.id);
|
id: string;
|
||||||
|
}[]
|
||||||
|
).map((row) => row.id);
|
||||||
const secondEmbeddingCount = (
|
const secondEmbeddingCount = (
|
||||||
db.prepare(`SELECT COUNT(*) as n FROM snippet_embeddings WHERE profile_id = 'local-default'`).get() as {
|
db
|
||||||
|
.prepare(`SELECT COUNT(*) as n FROM snippet_embeddings WHERE profile_id = 'local-default'`)
|
||||||
|
.get() as {
|
||||||
n: number;
|
n: number;
|
||||||
}
|
}
|
||||||
).n;
|
).n;
|
||||||
@@ -539,6 +552,52 @@ describe('IndexingPipeline', () => {
|
|||||||
expect(finalChecksum).toBe('sha-v2');
|
expect(finalChecksum).toBe('sha-v2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes derived vec rows when changed documents are replaced', async () => {
|
||||||
|
const docId = crypto.randomUUID();
|
||||||
|
const snippetId = crypto.randomUUID();
|
||||||
|
const embedding = Float32Array.from([1, 0, 0]);
|
||||||
|
const vecStore = new SqliteVecStore(db);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO documents (id, repository_id, version_id, file_path, checksum, indexed_at)
|
||||||
|
VALUES (?, '/test/repo', NULL, 'README.md', 'stale-doc', ?)`
|
||||||
|
).run(docId, now);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO snippets (id, document_id, repository_id, version_id, type, content, created_at)
|
||||||
|
VALUES (?, ?, '/test/repo', NULL, 'info', 'stale snippet', ?)`
|
||||||
|
).run(snippetId, docId, now);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
|
||||||
|
VALUES (?, 'local-default', 'test-model', 3, ?, ?)`
|
||||||
|
).run(snippetId, Buffer.from(embedding.buffer), now);
|
||||||
|
vecStore.upsertEmbedding('local-default', snippetId, embedding);
|
||||||
|
|
||||||
|
const pipeline = makePipeline({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
path: 'README.md',
|
||||||
|
content: '# Updated\n\nFresh content.',
|
||||||
|
sha: 'sha-fresh',
|
||||||
|
language: 'markdown'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFiles: 1
|
||||||
|
});
|
||||||
|
const job = makeJob();
|
||||||
|
|
||||||
|
await pipeline.run(job as never);
|
||||||
|
|
||||||
|
const vecTable = sqliteVecTableName('local-default');
|
||||||
|
const rowidTable = sqliteVecRowidTableName('local-default');
|
||||||
|
const vecCount = db.prepare(`SELECT COUNT(*) as n FROM "${vecTable}"`).get() as { n: number };
|
||||||
|
const rowidCount = db.prepare(`SELECT COUNT(*) as n FROM "${rowidTable}"`).get() as {
|
||||||
|
n: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(vecCount.n).toBe(0);
|
||||||
|
expect(rowidCount.n).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('updates job progress as files are processed', async () => {
|
it('updates job progress as files are processed', async () => {
|
||||||
const files = Array.from({ length: 5 }, (_, i) => ({
|
const files = Array.from({ length: 5 }, (_, i) => ({
|
||||||
path: `file${i}.md`,
|
path: `file${i}.md`,
|
||||||
@@ -700,6 +759,60 @@ describe('IndexingPipeline', () => {
|
|||||||
expect(version.indexed_at).not.toBeNull();
|
expect(version.indexed_at).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clones ancestor embeddings into the derived vec store for differential indexing', async () => {
|
||||||
|
const ancestorVersionId = insertVersion(db, { tag: 'v1.0.0', state: 'indexed' });
|
||||||
|
const targetVersionId = insertVersion(db, { tag: 'v1.1.0', state: 'pending' });
|
||||||
|
const vecStore = new SqliteVecStore(db);
|
||||||
|
const docId = crypto.randomUUID();
|
||||||
|
const snippetId = crypto.randomUUID();
|
||||||
|
const embedding = Float32Array.from([0.2, 0.4, 0.6]);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO documents (id, repository_id, version_id, file_path, checksum, indexed_at)
|
||||||
|
VALUES (?, '/test/repo', ?, 'README.md', 'ancestor-doc', ?)`
|
||||||
|
).run(docId, ancestorVersionId, now);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO snippets (id, document_id, repository_id, version_id, type, content, created_at)
|
||||||
|
VALUES (?, ?, '/test/repo', ?, 'info', 'ancestor snippet', ?)`
|
||||||
|
).run(snippetId, docId, ancestorVersionId, now);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
|
||||||
|
VALUES (?, 'local-default', 'test-model', 3, ?, ?)`
|
||||||
|
).run(snippetId, Buffer.from(embedding.buffer), now);
|
||||||
|
vecStore.upsertEmbedding('local-default', snippetId, embedding);
|
||||||
|
|
||||||
|
vi.spyOn(diffStrategy, 'buildDifferentialPlan').mockResolvedValue({
|
||||||
|
ancestorTag: 'v1.0.0',
|
||||||
|
ancestorVersionId,
|
||||||
|
changedPaths: new Set<string>(),
|
||||||
|
unchangedPaths: new Set<string>(['README.md'])
|
||||||
|
});
|
||||||
|
|
||||||
|
const pipeline = makePipeline({ files: [], totalFiles: 0 });
|
||||||
|
const job = makeJob('/test/repo', targetVersionId);
|
||||||
|
|
||||||
|
await pipeline.run(job as never);
|
||||||
|
|
||||||
|
const targetRows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT se.snippet_id, se.embedding
|
||||||
|
FROM snippet_embeddings se
|
||||||
|
INNER JOIN snippets s ON s.id = se.snippet_id
|
||||||
|
WHERE s.version_id = ?`
|
||||||
|
)
|
||||||
|
.all(targetVersionId) as Array<{ snippet_id: string; embedding: Buffer }>;
|
||||||
|
|
||||||
|
expect(targetRows).toHaveLength(1);
|
||||||
|
const matches = vecStore.queryNearestNeighbors(embedding, {
|
||||||
|
repositoryId: '/test/repo',
|
||||||
|
versionId: targetVersionId,
|
||||||
|
profileId: 'local-default',
|
||||||
|
limit: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(matches[0]?.snippetId).toBe(targetRows[0].snippet_id);
|
||||||
|
});
|
||||||
|
|
||||||
it('updates repository_versions state to error when pipeline throws and job has versionId', async () => {
|
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 versionId = insertVersion(db, { tag: 'v1.0.0', state: 'pending' });
|
||||||
const errorCrawl = vi.fn().mockRejectedValue(new Error('crawl failed'));
|
const errorCrawl = vi.fn().mockRejectedValue(new Error('crawl failed'));
|
||||||
@@ -812,9 +925,9 @@ describe('IndexingPipeline', () => {
|
|||||||
|
|
||||||
await pipeline.run(job as never);
|
await pipeline.run(job as never);
|
||||||
|
|
||||||
const docs = db
|
const docs = db.prepare(`SELECT file_path FROM documents ORDER BY file_path`).all() as {
|
||||||
.prepare(`SELECT file_path FROM documents ORDER BY file_path`)
|
file_path: string;
|
||||||
.all() as { file_path: string }[];
|
}[];
|
||||||
const filePaths = docs.map((d) => d.file_path);
|
const filePaths = docs.map((d) => d.file_path);
|
||||||
|
|
||||||
// migration-guide.md and docs/legacy-api.md must be absent.
|
// migration-guide.md and docs/legacy-api.md must be absent.
|
||||||
@@ -850,7 +963,10 @@ describe('IndexingPipeline', () => {
|
|||||||
|
|
||||||
expect(row).toBeDefined();
|
expect(row).toBeDefined();
|
||||||
const rules = JSON.parse(row!.rules);
|
const rules = JSON.parse(row!.rules);
|
||||||
expect(rules).toEqual(['Always use TypeScript strict mode', 'Prefer async/await over callbacks']);
|
expect(rules).toEqual([
|
||||||
|
'Always use TypeScript strict mode',
|
||||||
|
'Prefer async/await over callbacks'
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists version-specific rules under (repositoryId, versionId) when job has versionId', async () => {
|
it('persists version-specific rules under (repositoryId, versionId) when job has versionId', async () => {
|
||||||
@@ -1113,12 +1229,7 @@ describe('differential indexing', () => {
|
|||||||
insertSnippet(db, doc1Id, { repository_id: '/test/repo', version_id: ancestorVersionId });
|
insertSnippet(db, doc1Id, { repository_id: '/test/repo', version_id: ancestorVersionId });
|
||||||
insertSnippet(db, doc2Id, { repository_id: '/test/repo', version_id: ancestorVersionId });
|
insertSnippet(db, doc2Id, { repository_id: '/test/repo', version_id: ancestorVersionId });
|
||||||
|
|
||||||
const pipeline = new IndexingPipeline(
|
const pipeline = new IndexingPipeline(db, vi.fn() as never, { crawl: vi.fn() } as never, null);
|
||||||
db,
|
|
||||||
vi.fn() as never,
|
|
||||||
{ crawl: vi.fn() } as never,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
(pipeline as unknown as PipelineInternals).cloneFromAncestor(
|
(pipeline as unknown as PipelineInternals).cloneFromAncestor(
|
||||||
ancestorVersionId,
|
ancestorVersionId,
|
||||||
targetVersionId,
|
targetVersionId,
|
||||||
@@ -1130,9 +1241,7 @@ describe('differential indexing', () => {
|
|||||||
.prepare(`SELECT * FROM documents WHERE version_id = ?`)
|
.prepare(`SELECT * FROM documents WHERE version_id = ?`)
|
||||||
.all(targetVersionId) as { id: string; file_path: string }[];
|
.all(targetVersionId) as { id: string; file_path: string }[];
|
||||||
expect(targetDocs).toHaveLength(2);
|
expect(targetDocs).toHaveLength(2);
|
||||||
expect(targetDocs.map((d) => d.file_path).sort()).toEqual(
|
expect(targetDocs.map((d) => d.file_path).sort()).toEqual(['README.md', 'src/index.ts'].sort());
|
||||||
['README.md', 'src/index.ts'].sort()
|
|
||||||
);
|
|
||||||
// New IDs must differ from ancestor doc IDs.
|
// New IDs must differ from ancestor doc IDs.
|
||||||
const targetDocIds = targetDocs.map((d) => d.id);
|
const targetDocIds = targetDocs.map((d) => d.id);
|
||||||
expect(targetDocIds).not.toContain(doc1Id);
|
expect(targetDocIds).not.toContain(doc1Id);
|
||||||
@@ -1155,12 +1264,7 @@ describe('differential indexing', () => {
|
|||||||
checksum: 'sha-main'
|
checksum: 'sha-main'
|
||||||
});
|
});
|
||||||
|
|
||||||
const pipeline = new IndexingPipeline(
|
const pipeline = new IndexingPipeline(db, vi.fn() as never, { crawl: vi.fn() } as never, null);
|
||||||
db,
|
|
||||||
vi.fn() as never,
|
|
||||||
{ crawl: vi.fn() } as never,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
(pipeline as unknown as PipelineInternals).cloneFromAncestor(
|
(pipeline as unknown as PipelineInternals).cloneFromAncestor(
|
||||||
ancestorVersionId,
|
ancestorVersionId,
|
||||||
targetVersionId,
|
targetVersionId,
|
||||||
@@ -1217,9 +1321,9 @@ describe('differential indexing', () => {
|
|||||||
|
|
||||||
await pipeline.run(job);
|
await pipeline.run(job);
|
||||||
|
|
||||||
const updatedJob = db
|
const updatedJob = db.prepare(`SELECT status FROM indexing_jobs WHERE id = ?`).get(jobId) as {
|
||||||
.prepare(`SELECT status FROM indexing_jobs WHERE id = ?`)
|
status: string;
|
||||||
.get(jobId) as { status: string };
|
};
|
||||||
expect(updatedJob.status).toBe('done');
|
expect(updatedJob.status).toBe('done');
|
||||||
|
|
||||||
const docs = db
|
const docs = db
|
||||||
@@ -1269,9 +1373,7 @@ describe('differential indexing', () => {
|
|||||||
deletedPaths: new Set<string>(),
|
deletedPaths: new Set<string>(),
|
||||||
unchangedPaths: new Set(['unchanged.md'])
|
unchangedPaths: new Set(['unchanged.md'])
|
||||||
};
|
};
|
||||||
const spy = vi
|
const spy = vi.spyOn(diffStrategy, 'buildDifferentialPlan').mockResolvedValueOnce(mockPlan);
|
||||||
.spyOn(diffStrategy, 'buildDifferentialPlan')
|
|
||||||
.mockResolvedValueOnce(mockPlan);
|
|
||||||
|
|
||||||
const pipeline = new IndexingPipeline(
|
const pipeline = new IndexingPipeline(
|
||||||
db,
|
db,
|
||||||
@@ -1292,9 +1394,9 @@ describe('differential indexing', () => {
|
|||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
// 6. Assert job completed and both docs exist under the target version.
|
// 6. Assert job completed and both docs exist under the target version.
|
||||||
const finalJob = db
|
const finalJob = db.prepare(`SELECT status FROM indexing_jobs WHERE id = ?`).get(jobId) as {
|
||||||
.prepare(`SELECT status FROM indexing_jobs WHERE id = ?`)
|
status: string;
|
||||||
.get(jobId) as { status: string };
|
};
|
||||||
expect(finalJob.status).toBe('done');
|
expect(finalJob.status).toBe('done');
|
||||||
|
|
||||||
const targetDocs = db
|
const targetDocs = db
|
||||||
|
|||||||
@@ -22,11 +22,20 @@ import type { EmbeddingService } from '$lib/server/embeddings/embedding.service.
|
|||||||
import { RepositoryMapper } from '$lib/server/mappers/repository.mapper.js';
|
import { RepositoryMapper } from '$lib/server/mappers/repository.mapper.js';
|
||||||
import { IndexingJob } from '$lib/server/models/indexing-job.js';
|
import { IndexingJob } from '$lib/server/models/indexing-job.js';
|
||||||
import { Repository, RepositoryEntity } from '$lib/server/models/repository.js';
|
import { Repository, RepositoryEntity } from '$lib/server/models/repository.js';
|
||||||
|
import { SqliteVecStore } from '$lib/server/search/sqlite-vec.store.js';
|
||||||
import { resolveConfig, type ParsedConfig } from '$lib/server/config/config-parser.js';
|
import { resolveConfig, type ParsedConfig } from '$lib/server/config/config-parser.js';
|
||||||
import { parseFile } from '$lib/server/parser/index.js';
|
import { parseFile } from '$lib/server/parser/index.js';
|
||||||
import { computeTrustScore } from '$lib/server/search/trust-score.js';
|
import { computeTrustScore } from '$lib/server/search/trust-score.js';
|
||||||
import { computeDiff } from './diff.js';
|
import { computeDiff } from './diff.js';
|
||||||
import { buildDifferentialPlan, type DifferentialPlan } from './differential-strategy.js';
|
import { buildDifferentialPlan, type DifferentialPlan } from './differential-strategy.js';
|
||||||
|
import {
|
||||||
|
cloneFromAncestor as cloneFromAncestorInDatabase,
|
||||||
|
replaceSnippets as replaceSnippetsInDatabase,
|
||||||
|
updateRepo as updateRepoInDatabase,
|
||||||
|
updateVersion as updateVersionInDatabase,
|
||||||
|
type CloneFromAncestorRequest
|
||||||
|
} from './write-operations.js';
|
||||||
|
import type { SerializedFields } from './worker-types.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Progress calculation
|
// Progress calculation
|
||||||
@@ -63,12 +72,32 @@ function sha256(content: string): string {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class IndexingPipeline {
|
export class IndexingPipeline {
|
||||||
|
private readonly sqliteVecStore: SqliteVecStore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly db: Database.Database,
|
private readonly db: Database.Database,
|
||||||
private readonly githubCrawl: typeof GithubCrawlFn,
|
private readonly githubCrawl: typeof GithubCrawlFn,
|
||||||
private readonly localCrawler: LocalCrawler,
|
private readonly localCrawler: LocalCrawler,
|
||||||
private readonly embeddingService: EmbeddingService | null
|
private readonly embeddingService: EmbeddingService | null,
|
||||||
) {}
|
private readonly writeDelegate?: {
|
||||||
|
persistJobUpdates?: boolean;
|
||||||
|
replaceSnippets?: (
|
||||||
|
changedDocIds: string[],
|
||||||
|
newDocuments: NewDocument[],
|
||||||
|
newSnippets: NewSnippet[]
|
||||||
|
) => Promise<void>;
|
||||||
|
cloneFromAncestor?: (request: CloneFromAncestorRequest) => Promise<void>;
|
||||||
|
updateRepo?: (repositoryId: string, fields: SerializedFields) => Promise<void>;
|
||||||
|
updateVersion?: (versionId: string, fields: SerializedFields) => Promise<void>;
|
||||||
|
upsertRepoConfig?: (
|
||||||
|
repositoryId: string,
|
||||||
|
versionId: string | null,
|
||||||
|
rules: string[]
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
this.sqliteVecStore = new SqliteVecStore(db);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Public — run a job end to end
|
// Public — run a job end to end
|
||||||
@@ -112,14 +141,12 @@ export class IndexingPipeline {
|
|||||||
if (!repo) throw new Error(`Repository ${repositoryId} not found`);
|
if (!repo) throw new Error(`Repository ${repositoryId} not found`);
|
||||||
|
|
||||||
// Mark repo as actively indexing.
|
// Mark repo as actively indexing.
|
||||||
this.updateRepo(repo.id, { state: 'indexing' });
|
await this.updateRepo(repo.id, { state: 'indexing' });
|
||||||
if (normJob.versionId) {
|
if (normJob.versionId) {
|
||||||
this.updateVersion(normJob.versionId, { state: 'indexing' });
|
await this.updateVersion(normJob.versionId, { state: 'indexing' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionTag = normJob.versionId
|
const versionTag = normJob.versionId ? this.getVersionTag(normJob.versionId) : undefined;
|
||||||
? this.getVersionTag(normJob.versionId)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// ---- Stage 0: Differential strategy (TRUEREF-0021) ----------------------
|
// ---- Stage 0: Differential strategy (TRUEREF-0021) ----------------------
|
||||||
// When indexing a tagged version, check if we can inherit unchanged files
|
// When indexing a tagged version, check if we can inherit unchanged files
|
||||||
@@ -142,12 +169,12 @@ export class IndexingPipeline {
|
|||||||
// If a differential plan exists, clone unchanged files from ancestor.
|
// If a differential plan exists, clone unchanged files from ancestor.
|
||||||
if (differentialPlan && differentialPlan.unchangedPaths.size > 0) {
|
if (differentialPlan && differentialPlan.unchangedPaths.size > 0) {
|
||||||
reportStage('cloning');
|
reportStage('cloning');
|
||||||
this.cloneFromAncestor(
|
await this.cloneFromAncestor({
|
||||||
differentialPlan.ancestorVersionId,
|
ancestorVersionId: differentialPlan.ancestorVersionId,
|
||||||
normJob.versionId!,
|
targetVersionId: normJob.versionId!,
|
||||||
repo.id,
|
repositoryId: repo.id,
|
||||||
differentialPlan.unchangedPaths
|
unchangedPaths: [...differentialPlan.unchangedPaths]
|
||||||
);
|
});
|
||||||
console.info(
|
console.info(
|
||||||
`[IndexingPipeline] Differential indexing: cloned ${differentialPlan.unchangedPaths.size} unchanged files from ${differentialPlan.ancestorTag}`
|
`[IndexingPipeline] Differential indexing: cloned ${differentialPlan.unchangedPaths.size} unchanged files from ${differentialPlan.ancestorTag}`
|
||||||
);
|
);
|
||||||
@@ -169,7 +196,11 @@ export class IndexingPipeline {
|
|||||||
if (crawlResult.config) {
|
if (crawlResult.config) {
|
||||||
// Config was pre-parsed by the crawler — wrap it in a ParsedConfig
|
// Config was pre-parsed by the crawler — wrap it in a ParsedConfig
|
||||||
// shell so the rest of the pipeline can use it uniformly.
|
// shell so the rest of the pipeline can use it uniformly.
|
||||||
parsedConfig = { config: crawlResult.config, source: 'trueref.json', warnings: [] } satisfies ParsedConfig;
|
parsedConfig = {
|
||||||
|
config: crawlResult.config,
|
||||||
|
source: 'trueref.json',
|
||||||
|
warnings: []
|
||||||
|
} satisfies ParsedConfig;
|
||||||
} else {
|
} else {
|
||||||
const configFile = crawlResult.files.find(
|
const configFile = crawlResult.files.find(
|
||||||
(f) => f.path === 'trueref.json' || f.path === 'context7.json'
|
(f) => f.path === 'trueref.json' || f.path === 'context7.json'
|
||||||
@@ -184,7 +215,10 @@ export class IndexingPipeline {
|
|||||||
const filteredFiles =
|
const filteredFiles =
|
||||||
excludeFiles.length > 0
|
excludeFiles.length > 0
|
||||||
? crawlResult.files.filter(
|
? crawlResult.files.filter(
|
||||||
(f) => !excludeFiles.some((pattern) => IndexingPipeline.matchesExcludePattern(f.path, pattern))
|
(f) =>
|
||||||
|
!excludeFiles.some((pattern) =>
|
||||||
|
IndexingPipeline.matchesExcludePattern(f.path, pattern)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
: crawlResult.files;
|
: crawlResult.files;
|
||||||
|
|
||||||
@@ -298,7 +332,13 @@ export class IndexingPipeline {
|
|||||||
this.embeddingService !== null
|
this.embeddingService !== null
|
||||||
);
|
);
|
||||||
this.updateJob(job.id, { processedFiles: totalProcessed, progress });
|
this.updateJob(job.id, { processedFiles: totalProcessed, progress });
|
||||||
reportStage('parsing', `${totalProcessed} / ${totalFiles} files`, progress, totalProcessed, totalFiles);
|
reportStage(
|
||||||
|
'parsing',
|
||||||
|
`${totalProcessed} / ${totalFiles} files`,
|
||||||
|
progress,
|
||||||
|
totalProcessed,
|
||||||
|
totalFiles
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +347,7 @@ export class IndexingPipeline {
|
|||||||
|
|
||||||
// ---- Stage 3: Atomic replacement ------------------------------------
|
// ---- Stage 3: Atomic replacement ------------------------------------
|
||||||
reportStage('storing');
|
reportStage('storing');
|
||||||
this.replaceSnippets(repo.id, changedDocIds, newDocuments, newSnippets);
|
await this.replaceSnippets(repo.id, changedDocIds, newDocuments, newSnippets);
|
||||||
|
|
||||||
// ---- Stage 4: Embeddings (if provider is configured) ----------------
|
// ---- Stage 4: Embeddings (if provider is configured) ----------------
|
||||||
if (this.embeddingService) {
|
if (this.embeddingService) {
|
||||||
@@ -320,7 +360,7 @@ export class IndexingPipeline {
|
|||||||
if (snippetIds.length === 0) {
|
if (snippetIds.length === 0) {
|
||||||
// No missing embeddings for the active profile; parsing progress is final.
|
// No missing embeddings for the active profile; parsing progress is final.
|
||||||
} else {
|
} else {
|
||||||
const embeddingsTotal = snippetIds.length;
|
const embeddingsTotal = snippetIds.length;
|
||||||
|
|
||||||
await this.embeddingService.embedSnippets(snippetIds, (done) => {
|
await this.embeddingService.embedSnippets(snippetIds, (done) => {
|
||||||
const progress = calculateProgress(
|
const progress = calculateProgress(
|
||||||
@@ -345,7 +385,7 @@ export class IndexingPipeline {
|
|||||||
state: 'indexed'
|
state: 'indexed'
|
||||||
});
|
});
|
||||||
|
|
||||||
this.updateRepo(repo.id, {
|
await this.updateRepo(repo.id, {
|
||||||
state: 'indexed',
|
state: 'indexed',
|
||||||
totalSnippets: stats.totalSnippets,
|
totalSnippets: stats.totalSnippets,
|
||||||
totalTokens: stats.totalTokens,
|
totalTokens: stats.totalTokens,
|
||||||
@@ -355,7 +395,7 @@ export class IndexingPipeline {
|
|||||||
|
|
||||||
if (normJob.versionId) {
|
if (normJob.versionId) {
|
||||||
const versionStats = this.computeVersionStats(normJob.versionId);
|
const versionStats = this.computeVersionStats(normJob.versionId);
|
||||||
this.updateVersion(normJob.versionId, {
|
await this.updateVersion(normJob.versionId, {
|
||||||
state: 'indexed',
|
state: 'indexed',
|
||||||
totalSnippets: versionStats.totalSnippets,
|
totalSnippets: versionStats.totalSnippets,
|
||||||
indexedAt: Math.floor(Date.now() / 1000)
|
indexedAt: Math.floor(Date.now() / 1000)
|
||||||
@@ -366,12 +406,12 @@ export class IndexingPipeline {
|
|||||||
if (parsedConfig?.config.rules?.length) {
|
if (parsedConfig?.config.rules?.length) {
|
||||||
if (!normJob.versionId) {
|
if (!normJob.versionId) {
|
||||||
// Main-branch job: write the repo-wide entry only.
|
// Main-branch job: write the repo-wide entry only.
|
||||||
this.upsertRepoConfig(repo.id, null, parsedConfig.config.rules);
|
await this.upsertRepoConfig(repo.id, null, parsedConfig.config.rules);
|
||||||
} else {
|
} else {
|
||||||
// Version job: write only the version-specific entry.
|
// Version job: write only the version-specific entry.
|
||||||
// Writing to the NULL row here would overwrite repo-wide rules
|
// Writing to the NULL row here would overwrite repo-wide rules
|
||||||
// with whatever the last-indexed version happened to carry.
|
// with whatever the last-indexed version happened to carry.
|
||||||
this.upsertRepoConfig(repo.id, normJob.versionId, parsedConfig.config.rules);
|
await this.upsertRepoConfig(repo.id, normJob.versionId, parsedConfig.config.rules);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,9 +433,9 @@ export class IndexingPipeline {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Restore repo to error state but preserve any existing indexed data.
|
// Restore repo to error state but preserve any existing indexed data.
|
||||||
this.updateRepo(repositoryId, { state: 'error' });
|
await this.updateRepo(repositoryId, { state: 'error' });
|
||||||
if (normJob.versionId) {
|
if (normJob.versionId) {
|
||||||
this.updateVersion(normJob.versionId, { state: 'error' });
|
await this.updateVersion(normJob.versionId, { state: 'error' });
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
@@ -406,7 +446,11 @@ export class IndexingPipeline {
|
|||||||
// Private — crawl
|
// Private — crawl
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private async crawl(repo: Repository, ref?: string, allowedPaths?: Set<string>): Promise<{
|
private async crawl(
|
||||||
|
repo: Repository,
|
||||||
|
ref?: string,
|
||||||
|
allowedPaths?: Set<string>
|
||||||
|
): Promise<{
|
||||||
files: Array<{ path: string; content: string; sha: string; size: number; language: string }>;
|
files: Array<{ path: string; content: string; sha: string; size: number; language: string }>;
|
||||||
totalFiles: number;
|
totalFiles: number;
|
||||||
/** Pre-parsed trueref.json / context7.json, or undefined when absent. */
|
/** Pre-parsed trueref.json / context7.json, or undefined when absent. */
|
||||||
@@ -468,211 +512,50 @@ export class IndexingPipeline {
|
|||||||
*
|
*
|
||||||
* Runs in a single SQLite transaction for atomicity.
|
* Runs in a single SQLite transaction for atomicity.
|
||||||
*/
|
*/
|
||||||
private cloneFromAncestor(
|
private async cloneFromAncestor(
|
||||||
ancestorVersionId: string,
|
requestOrAncestorVersionId: CloneFromAncestorRequest | string,
|
||||||
targetVersionId: string,
|
targetVersionId?: string,
|
||||||
repositoryId: string,
|
repositoryId?: string,
|
||||||
unchangedPaths: Set<string>
|
unchangedPaths?: Set<string>
|
||||||
): void {
|
): Promise<void> {
|
||||||
this.db.transaction(() => {
|
const request: CloneFromAncestorRequest =
|
||||||
const pathList = [...unchangedPaths];
|
typeof requestOrAncestorVersionId === 'string'
|
||||||
const placeholders = pathList.map(() => '?').join(',');
|
? {
|
||||||
const ancestorDocs = this.db
|
ancestorVersionId: requestOrAncestorVersionId,
|
||||||
.prepare(
|
targetVersionId: targetVersionId!,
|
||||||
`SELECT * FROM documents WHERE version_id = ? AND file_path IN (${placeholders})`
|
repositoryId: repositoryId!,
|
||||||
)
|
unchangedPaths: [...(unchangedPaths ?? new Set<string>())]
|
||||||
.all(ancestorVersionId, ...pathList) as Array<{
|
}
|
||||||
id: string;
|
: requestOrAncestorVersionId;
|
||||||
repository_id: string;
|
|
||||||
file_path: string;
|
|
||||||
title: string | null;
|
|
||||||
language: string | null;
|
|
||||||
token_count: number;
|
|
||||||
checksum: string;
|
|
||||||
indexed_at: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const docIdMap = new Map<string, string>();
|
if (request.unchangedPaths.length === 0) {
|
||||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const doc of ancestorDocs) {
|
if (this.writeDelegate?.cloneFromAncestor) {
|
||||||
const newDocId = randomUUID();
|
await this.writeDelegate.cloneFromAncestor(request);
|
||||||
docIdMap.set(doc.id, newDocId);
|
return;
|
||||||
this.db
|
}
|
||||||
.prepare(
|
|
||||||
`INSERT INTO documents (id, repository_id, version_id, file_path, title, language, token_count, checksum, indexed_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
||||||
)
|
|
||||||
.run(
|
|
||||||
newDocId,
|
|
||||||
repositoryId,
|
|
||||||
targetVersionId,
|
|
||||||
doc.file_path,
|
|
||||||
doc.title,
|
|
||||||
doc.language,
|
|
||||||
doc.token_count,
|
|
||||||
doc.checksum,
|
|
||||||
nowEpoch
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (docIdMap.size === 0) return;
|
cloneFromAncestorInDatabase(this.db, request);
|
||||||
|
|
||||||
const oldDocIds = [...docIdMap.keys()];
|
|
||||||
const snippetPlaceholders = oldDocIds.map(() => '?').join(',');
|
|
||||||
const ancestorSnippets = this.db
|
|
||||||
.prepare(
|
|
||||||
`SELECT * FROM snippets WHERE document_id IN (${snippetPlaceholders})`
|
|
||||||
)
|
|
||||||
.all(...oldDocIds) as Array<{
|
|
||||||
id: string;
|
|
||||||
document_id: string;
|
|
||||||
repository_id: string;
|
|
||||||
version_id: string | null;
|
|
||||||
type: string;
|
|
||||||
title: string | null;
|
|
||||||
content: string;
|
|
||||||
language: string | null;
|
|
||||||
breadcrumb: string | null;
|
|
||||||
token_count: number;
|
|
||||||
created_at: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const snippetIdMap = new Map<string, string>();
|
|
||||||
for (const snippet of ancestorSnippets) {
|
|
||||||
const newSnippetId = randomUUID();
|
|
||||||
snippetIdMap.set(snippet.id, newSnippetId);
|
|
||||||
const newDocId = docIdMap.get(snippet.document_id)!;
|
|
||||||
this.db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO snippets (id, document_id, repository_id, version_id, type, title, content, language, breadcrumb, token_count, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
||||||
)
|
|
||||||
.run(
|
|
||||||
newSnippetId,
|
|
||||||
newDocId,
|
|
||||||
repositoryId,
|
|
||||||
targetVersionId,
|
|
||||||
snippet.type,
|
|
||||||
snippet.title,
|
|
||||||
snippet.content,
|
|
||||||
snippet.language,
|
|
||||||
snippet.breadcrumb,
|
|
||||||
snippet.token_count,
|
|
||||||
snippet.created_at
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snippetIdMap.size > 0) {
|
|
||||||
const oldSnippetIds = [...snippetIdMap.keys()];
|
|
||||||
const embPlaceholders = oldSnippetIds.map(() => '?').join(',');
|
|
||||||
const ancestorEmbeddings = this.db
|
|
||||||
.prepare(
|
|
||||||
`SELECT * FROM snippet_embeddings WHERE snippet_id IN (${embPlaceholders})`
|
|
||||||
)
|
|
||||||
.all(...oldSnippetIds) as Array<{
|
|
||||||
snippet_id: string;
|
|
||||||
profile_id: string;
|
|
||||||
model: string;
|
|
||||||
dimensions: number;
|
|
||||||
embedding: Buffer;
|
|
||||||
created_at: number;
|
|
||||||
}>;
|
|
||||||
for (const emb of ancestorEmbeddings) {
|
|
||||||
const newSnippetId = snippetIdMap.get(emb.snippet_id)!;
|
|
||||||
this.db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
|
||||||
)
|
|
||||||
.run(
|
|
||||||
newSnippetId,
|
|
||||||
emb.profile_id,
|
|
||||||
emb.model,
|
|
||||||
emb.dimensions,
|
|
||||||
emb.embedding,
|
|
||||||
emb.created_at
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Private — atomic snippet replacement
|
// Private — atomic snippet replacement
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private replaceSnippets(
|
private async replaceSnippets(
|
||||||
_repositoryId: string,
|
_repositoryId: string,
|
||||||
changedDocIds: string[],
|
changedDocIds: string[],
|
||||||
newDocuments: NewDocument[],
|
newDocuments: NewDocument[],
|
||||||
newSnippets: NewSnippet[]
|
newSnippets: NewSnippet[]
|
||||||
): void {
|
): Promise<void> {
|
||||||
const insertDoc = this.db.prepare(
|
if (this.writeDelegate?.replaceSnippets) {
|
||||||
`INSERT INTO documents
|
await this.writeDelegate.replaceSnippets(changedDocIds, newDocuments, newSnippets);
|
||||||
(id, repository_id, version_id, file_path, title, language,
|
return;
|
||||||
token_count, checksum, indexed_at)
|
}
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
||||||
);
|
|
||||||
|
|
||||||
const insertSnippet = this.db.prepare(
|
replaceSnippetsInDatabase(this.db, changedDocIds, newDocuments, newSnippets);
|
||||||
`INSERT INTO snippets
|
|
||||||
(id, document_id, repository_id, version_id, type, title,
|
|
||||||
content, language, breadcrumb, token_count, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
||||||
);
|
|
||||||
|
|
||||||
this.db.transaction(() => {
|
|
||||||
// Delete stale documents (cascade deletes their snippets via FK).
|
|
||||||
if (changedDocIds.length > 0) {
|
|
||||||
const placeholders = changedDocIds.map(() => '?').join(',');
|
|
||||||
this.db
|
|
||||||
.prepare(`DELETE FROM documents WHERE id IN (${placeholders})`)
|
|
||||||
.run(...changedDocIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new documents.
|
|
||||||
for (const doc of newDocuments) {
|
|
||||||
const indexedAtSeconds =
|
|
||||||
doc.indexedAt instanceof Date
|
|
||||||
? Math.floor(doc.indexedAt.getTime() / 1000)
|
|
||||||
: Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
insertDoc.run(
|
|
||||||
doc.id,
|
|
||||||
doc.repositoryId,
|
|
||||||
doc.versionId ?? null,
|
|
||||||
doc.filePath,
|
|
||||||
doc.title ?? null,
|
|
||||||
doc.language ?? null,
|
|
||||||
doc.tokenCount ?? 0,
|
|
||||||
doc.checksum,
|
|
||||||
indexedAtSeconds
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new snippets.
|
|
||||||
for (const snippet of newSnippets) {
|
|
||||||
const createdAtSeconds =
|
|
||||||
snippet.createdAt instanceof Date
|
|
||||||
? Math.floor(snippet.createdAt.getTime() / 1000)
|
|
||||||
: Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
insertSnippet.run(
|
|
||||||
snippet.id,
|
|
||||||
snippet.documentId,
|
|
||||||
snippet.repositoryId,
|
|
||||||
snippet.versionId ?? null,
|
|
||||||
snippet.type,
|
|
||||||
snippet.title ?? null,
|
|
||||||
snippet.content,
|
|
||||||
snippet.language ?? null,
|
|
||||||
snippet.breadcrumb ?? null,
|
|
||||||
snippet.tokenCount ?? 0,
|
|
||||||
createdAtSeconds
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -696,9 +579,10 @@ export class IndexingPipeline {
|
|||||||
|
|
||||||
private computeVersionStats(versionId: string): { totalSnippets: number } {
|
private computeVersionStats(versionId: string): { totalSnippets: number } {
|
||||||
const row = this.db
|
const row = this.db
|
||||||
.prepare<[string], { total_snippets: number }>(
|
.prepare<
|
||||||
`SELECT COUNT(*) as total_snippets FROM snippets WHERE version_id = ?`
|
[string],
|
||||||
)
|
{ total_snippets: number }
|
||||||
|
>(`SELECT COUNT(*) as total_snippets FROM snippets WHERE version_id = ?`)
|
||||||
.get(versionId);
|
.get(versionId);
|
||||||
|
|
||||||
return { totalSnippets: row?.total_snippets ?? 0 };
|
return { totalSnippets: row?.total_snippets ?? 0 };
|
||||||
@@ -737,6 +621,10 @@ export class IndexingPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateJob(id: string, fields: Record<string, unknown>): void {
|
private updateJob(id: string, fields: Record<string, unknown>): void {
|
||||||
|
if (this.writeDelegate?.persistJobUpdates === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sets = Object.keys(fields)
|
const sets = Object.keys(fields)
|
||||||
.map((k) => `${toSnake(k)} = ?`)
|
.map((k) => `${toSnake(k)} = ?`)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
@@ -744,43 +632,44 @@ export class IndexingPipeline {
|
|||||||
this.db.prepare(`UPDATE indexing_jobs SET ${sets} WHERE id = ?`).run(...values);
|
this.db.prepare(`UPDATE indexing_jobs SET ${sets} WHERE id = ?`).run(...values);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateRepo(id: string, fields: Record<string, unknown>): void {
|
private async updateRepo(id: string, fields: SerializedFields): Promise<void> {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
if (this.writeDelegate?.updateRepo) {
|
||||||
const allFields = { ...fields, updatedAt: now };
|
await this.writeDelegate.updateRepo(id, fields);
|
||||||
const sets = Object.keys(allFields)
|
return;
|
||||||
.map((k) => `${toSnake(k)} = ?`)
|
}
|
||||||
.join(', ');
|
|
||||||
const values = [...Object.values(allFields), id];
|
updateRepoInDatabase(this.db, id, fields);
|
||||||
this.db.prepare(`UPDATE repositories SET ${sets} WHERE id = ?`).run(...values);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateVersion(id: string, fields: Record<string, unknown>): void {
|
private async updateVersion(id: string, fields: SerializedFields): Promise<void> {
|
||||||
const sets = Object.keys(fields)
|
if (this.writeDelegate?.updateVersion) {
|
||||||
.map((k) => `${toSnake(k)} = ?`)
|
await this.writeDelegate.updateVersion(id, fields);
|
||||||
.join(', ');
|
return;
|
||||||
const values = [...Object.values(fields), id];
|
}
|
||||||
this.db.prepare(`UPDATE repository_versions SET ${sets} WHERE id = ?`).run(...values);
|
|
||||||
|
updateVersionInDatabase(this.db, id, fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
private upsertRepoConfig(
|
private async upsertRepoConfig(
|
||||||
repositoryId: string,
|
repositoryId: string,
|
||||||
versionId: string | null,
|
versionId: string | null,
|
||||||
rules: string[]
|
rules: string[]
|
||||||
): void {
|
): Promise<void> {
|
||||||
|
if (this.writeDelegate?.upsertRepoConfig) {
|
||||||
|
await this.writeDelegate.upsertRepoConfig(repositoryId, versionId, rules);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
// Use DELETE + INSERT because ON CONFLICT … DO UPDATE doesn't work reliably
|
// Use DELETE + INSERT because ON CONFLICT … DO UPDATE doesn't work reliably
|
||||||
// with partial unique indexes in all SQLite versions.
|
// with partial unique indexes in all SQLite versions.
|
||||||
if (versionId === null) {
|
if (versionId === null) {
|
||||||
this.db
|
this.db
|
||||||
.prepare(
|
.prepare(`DELETE FROM repository_configs WHERE repository_id = ? AND version_id IS NULL`)
|
||||||
`DELETE FROM repository_configs WHERE repository_id = ? AND version_id IS NULL`
|
|
||||||
)
|
|
||||||
.run(repositoryId);
|
.run(repositoryId);
|
||||||
} else {
|
} else {
|
||||||
this.db
|
this.db
|
||||||
.prepare(
|
.prepare(`DELETE FROM repository_configs WHERE repository_id = ? AND version_id = ?`)
|
||||||
`DELETE FROM repository_configs WHERE repository_id = ? AND version_id = ?`
|
|
||||||
)
|
|
||||||
.run(repositoryId, versionId);
|
.run(repositoryId, versionId);
|
||||||
}
|
}
|
||||||
this.db
|
this.db
|
||||||
|
|||||||
@@ -17,6 +17,54 @@ import type { WorkerPool } from './worker-pool.js';
|
|||||||
|
|
||||||
const JOB_SELECT = `SELECT * FROM indexing_jobs`;
|
const JOB_SELECT = `SELECT * FROM indexing_jobs`;
|
||||||
|
|
||||||
|
type JobStatusFilter = IndexingJob['status'] | Array<IndexingJob['status']>;
|
||||||
|
|
||||||
|
function escapeLikePattern(value: string): string {
|
||||||
|
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSpecificRepositoryId(repositoryId: string): boolean {
|
||||||
|
return repositoryId.split('/').filter(Boolean).length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatuses(status?: JobStatusFilter): Array<IndexingJob['status']> {
|
||||||
|
if (!status) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses = Array.isArray(status) ? status : [status];
|
||||||
|
return [...new Set(statuses)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJobFilterQuery(options?: { repositoryId?: string; status?: JobStatusFilter }): {
|
||||||
|
where: string;
|
||||||
|
params: unknown[];
|
||||||
|
} {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (options?.repositoryId) {
|
||||||
|
if (isSpecificRepositoryId(options.repositoryId)) {
|
||||||
|
conditions.push('repository_id = ?');
|
||||||
|
params.push(options.repositoryId);
|
||||||
|
} else {
|
||||||
|
conditions.push(`(repository_id = ? OR repository_id LIKE ? ESCAPE '\\')`);
|
||||||
|
params.push(options.repositoryId, `${escapeLikePattern(options.repositoryId)}/%`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses = normalizeStatuses(options?.status);
|
||||||
|
if (statuses.length > 0) {
|
||||||
|
conditions.push(`status IN (${statuses.map(() => '?').join(', ')})`);
|
||||||
|
params.push(...statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
where: conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '',
|
||||||
|
params
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class JobQueue {
|
export class JobQueue {
|
||||||
private workerPool: WorkerPool | null = null;
|
private workerPool: WorkerPool | null = null;
|
||||||
|
|
||||||
@@ -116,7 +164,9 @@ export class JobQueue {
|
|||||||
*/
|
*/
|
||||||
private async processNext(): Promise<void> {
|
private async processNext(): Promise<void> {
|
||||||
// Fallback path: no worker pool configured, run directly (used by tests and dev mode)
|
// Fallback path: no worker pool configured, run directly (used by tests and dev mode)
|
||||||
console.warn('[JobQueue] Running in fallback mode (no worker pool) — direct pipeline execution.');
|
console.warn(
|
||||||
|
'[JobQueue] Running in fallback mode (no worker pool) — direct pipeline execution.'
|
||||||
|
);
|
||||||
|
|
||||||
const rawJob = this.db
|
const rawJob = this.db
|
||||||
.prepare<[], IndexingJobEntity>(
|
.prepare<[], IndexingJobEntity>(
|
||||||
@@ -128,7 +178,9 @@ export class JobQueue {
|
|||||||
|
|
||||||
if (!rawJob) return;
|
if (!rawJob) return;
|
||||||
|
|
||||||
console.warn('[JobQueue] processNext: no pipeline or pool configured — skipping job processing');
|
console.warn(
|
||||||
|
'[JobQueue] processNext: no pipeline or pool configured — skipping job processing'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,23 +196,11 @@ export class JobQueue {
|
|||||||
*/
|
*/
|
||||||
listJobs(options?: {
|
listJobs(options?: {
|
||||||
repositoryId?: string;
|
repositoryId?: string;
|
||||||
status?: IndexingJob['status'];
|
status?: JobStatusFilter;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): IndexingJob[] {
|
}): IndexingJob[] {
|
||||||
const limit = Math.min(options?.limit ?? 20, 200);
|
const limit = Math.min(options?.limit ?? 20, 200);
|
||||||
const conditions: string[] = [];
|
const { where, params } = buildJobFilterQuery(options);
|
||||||
const params: unknown[] = [];
|
|
||||||
|
|
||||||
if (options?.repositoryId) {
|
|
||||||
conditions.push('repository_id = ?');
|
|
||||||
params.push(options.repositoryId);
|
|
||||||
}
|
|
||||||
if (options?.status) {
|
|
||||||
conditions.push('status = ?');
|
|
||||||
params.push(options.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
||||||
const sql = `${JOB_SELECT} ${where} ORDER BY created_at DESC LIMIT ?`;
|
const sql = `${JOB_SELECT} ${where} ORDER BY created_at DESC LIMIT ?`;
|
||||||
params.push(limit);
|
params.push(limit);
|
||||||
|
|
||||||
@@ -194,19 +234,7 @@ export class JobQueue {
|
|||||||
* Count all jobs matching optional filters.
|
* Count all jobs matching optional filters.
|
||||||
*/
|
*/
|
||||||
countJobs(options?: { repositoryId?: string; status?: IndexingJob['status'] }): number {
|
countJobs(options?: { repositoryId?: string; status?: IndexingJob['status'] }): number {
|
||||||
const conditions: string[] = [];
|
const { where, params } = buildJobFilterQuery(options);
|
||||||
const params: unknown[] = [];
|
|
||||||
|
|
||||||
if (options?.repositoryId) {
|
|
||||||
conditions.push('repository_id = ?');
|
|
||||||
params.push(options.repositoryId);
|
|
||||||
}
|
|
||||||
if (options?.status) {
|
|
||||||
conditions.push('status = ?');
|
|
||||||
params.push(options.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND')}` : '';
|
|
||||||
const sql = `SELECT COUNT(*) as n FROM indexing_jobs ${where}`;
|
const sql = `SELECT COUNT(*) as n FROM indexing_jobs ${where}`;
|
||||||
const row = this.db.prepare<unknown[], { n: number }>(sql).get(...params);
|
const row = this.db.prepare<unknown[], { n: number }>(sql).get(...params);
|
||||||
return row?.n ?? 0;
|
return row?.n ?? 0;
|
||||||
|
|||||||
@@ -171,4 +171,27 @@ describe('ProgressBroadcaster', () => {
|
|||||||
reader1.cancel();
|
reader1.cancel();
|
||||||
reader2.cancel();
|
reader2.cancel();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('broadcastWorkerStatus sends worker-status events to global subscribers', async () => {
|
||||||
|
const broadcaster = new ProgressBroadcaster();
|
||||||
|
const stream = broadcaster.subscribeAll();
|
||||||
|
const reader = stream.getReader();
|
||||||
|
|
||||||
|
broadcaster.broadcastWorkerStatus({
|
||||||
|
concurrency: 2,
|
||||||
|
active: 1,
|
||||||
|
idle: 1,
|
||||||
|
workers: [
|
||||||
|
{ index: 0, state: 'running', jobId: 'job-1', repositoryId: '/repo/1', versionId: null }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const { value } = await reader.read();
|
||||||
|
const text = value as string;
|
||||||
|
|
||||||
|
expect(text).toContain('event: worker-status');
|
||||||
|
expect(text).toContain('"active":1');
|
||||||
|
|
||||||
|
reader.cancel();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export class ProgressBroadcaster {
|
|||||||
private allSubscribers = new Set<ReadableStreamDefaultController<string>>();
|
private allSubscribers = new Set<ReadableStreamDefaultController<string>>();
|
||||||
private lastEventCache = new Map<string, SSEEvent>();
|
private lastEventCache = new Map<string, SSEEvent>();
|
||||||
private eventCounters = new Map<string, number>();
|
private eventCounters = new Map<string, number>();
|
||||||
|
private globalEventCounter = 0;
|
||||||
|
|
||||||
subscribe(jobId: string): ReadableStream<string> {
|
subscribe(jobId: string): ReadableStream<string> {
|
||||||
return new ReadableStream({
|
return new ReadableStream({
|
||||||
@@ -135,6 +136,24 @@ export class ProgressBroadcaster {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
broadcastWorkerStatus(data: object): void {
|
||||||
|
this.globalEventCounter += 1;
|
||||||
|
const event: SSEEvent = {
|
||||||
|
id: this.globalEventCounter,
|
||||||
|
event: 'worker-status',
|
||||||
|
data: JSON.stringify(data)
|
||||||
|
};
|
||||||
|
const sse = this.formatSSE(event);
|
||||||
|
|
||||||
|
for (const controller of this.allSubscribers) {
|
||||||
|
try {
|
||||||
|
controller.enqueue(sse);
|
||||||
|
} catch {
|
||||||
|
// Controller might be closed or errored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getLastEvent(jobId: string): SSEEvent | null {
|
getLastEvent(jobId: string): SSEEvent | null {
|
||||||
return this.lastEventCache.get(jobId) ?? null;
|
return this.lastEventCache.get(jobId) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ import { LocalCrawler } from '$lib/server/crawler/local.crawler.js';
|
|||||||
import { IndexingPipeline } from './indexing.pipeline.js';
|
import { IndexingPipeline } from './indexing.pipeline.js';
|
||||||
import { JobQueue } from './job-queue.js';
|
import { JobQueue } from './job-queue.js';
|
||||||
import { WorkerPool } from './worker-pool.js';
|
import { WorkerPool } from './worker-pool.js';
|
||||||
import type { ParseWorkerResponse } from './worker-types.js';
|
|
||||||
import { initBroadcaster } from './progress-broadcaster.js';
|
import { initBroadcaster } from './progress-broadcaster.js';
|
||||||
import type { ProgressBroadcaster } from './progress-broadcaster.js';
|
import type { ProgressBroadcaster } from './progress-broadcaster.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -58,6 +58,21 @@ let _pipeline: IndexingPipeline | null = null;
|
|||||||
let _pool: WorkerPool | null = null;
|
let _pool: WorkerPool | null = null;
|
||||||
let _broadcaster: ProgressBroadcaster | null = null;
|
let _broadcaster: ProgressBroadcaster | null = null;
|
||||||
|
|
||||||
|
function resolveWorkerScript(...segments: string[]): string {
|
||||||
|
const candidates = [
|
||||||
|
path.resolve(process.cwd(), ...segments),
|
||||||
|
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../', ...segments)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise (or return the existing) JobQueue + IndexingPipeline pair.
|
* Initialise (or return the existing) JobQueue + IndexingPipeline pair.
|
||||||
*
|
*
|
||||||
@@ -90,51 +105,53 @@ export function initializePipeline(
|
|||||||
if (options?.dbPath) {
|
if (options?.dbPath) {
|
||||||
_broadcaster = initBroadcaster();
|
_broadcaster = initBroadcaster();
|
||||||
|
|
||||||
// Resolve worker script paths relative to this file (build/workers/ directory)
|
const getRepositoryIdForJob = (jobId: string): string => {
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const row = db
|
||||||
const __dirname = path.dirname(__filename);
|
.prepare<
|
||||||
const workerScript = path.join(__dirname, '../../../build/workers/worker-entry.mjs');
|
[string],
|
||||||
const embedWorkerScript = path.join(__dirname, '../../../build/workers/embed-worker-entry.mjs');
|
{ repository_id: string }
|
||||||
|
>(`SELECT repository_id FROM indexing_jobs WHERE id = ?`)
|
||||||
|
.get(jobId);
|
||||||
|
return row?.repository_id ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const workerScript = resolveWorkerScript('build', 'workers', 'worker-entry.mjs');
|
||||||
|
const embedWorkerScript = resolveWorkerScript('build', 'workers', 'embed-worker-entry.mjs');
|
||||||
|
const writeWorkerScript = resolveWorkerScript('build', 'workers', 'write-worker-entry.mjs');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_pool = new WorkerPool({
|
_pool = new WorkerPool({
|
||||||
concurrency: options.concurrency ?? 2,
|
concurrency: options.concurrency ?? 2,
|
||||||
workerScript,
|
workerScript,
|
||||||
embedWorkerScript,
|
embedWorkerScript,
|
||||||
|
writeWorkerScript,
|
||||||
dbPath: options.dbPath,
|
dbPath: options.dbPath,
|
||||||
onProgress: (jobId, msg) => {
|
onProgress: (jobId, msg) => {
|
||||||
// Update DB with progress
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE indexing_jobs
|
|
||||||
SET stage = ?, stage_detail = ?, progress = ?, processed_files = ?, total_files = ?
|
|
||||||
WHERE id = ?`
|
|
||||||
).run(msg.stage, msg.stageDetail ?? null, msg.progress, msg.processedFiles, msg.totalFiles, jobId);
|
|
||||||
|
|
||||||
// Broadcast progress event
|
// Broadcast progress event
|
||||||
if (_broadcaster) {
|
if (_broadcaster) {
|
||||||
_broadcaster.broadcast(jobId, '', 'progress', msg);
|
_broadcaster.broadcast(jobId, getRepositoryIdForJob(jobId), 'job-progress', {
|
||||||
|
...msg,
|
||||||
|
status: 'running'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onJobDone: (jobId: string) => {
|
onJobDone: (jobId: string) => {
|
||||||
// Update job status to done
|
|
||||||
db.prepare(`UPDATE indexing_jobs SET status = 'done', completed_at = unixepoch() WHERE id = ?`).run(
|
|
||||||
jobId
|
|
||||||
);
|
|
||||||
|
|
||||||
// Broadcast done event
|
// Broadcast done event
|
||||||
if (_broadcaster) {
|
if (_broadcaster) {
|
||||||
_broadcaster.broadcast(jobId, '', 'job-done', { jobId });
|
_broadcaster.broadcast(jobId, getRepositoryIdForJob(jobId), 'job-done', {
|
||||||
|
jobId,
|
||||||
|
status: 'done'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onJobFailed: (jobId: string, error: string) => {
|
onJobFailed: (jobId: string, error: string) => {
|
||||||
// Update job status to failed with error message
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE indexing_jobs SET status = 'failed', error = ?, completed_at = unixepoch() WHERE id = ?`
|
|
||||||
).run(error, jobId);
|
|
||||||
|
|
||||||
// Broadcast failed event
|
// Broadcast failed event
|
||||||
if (_broadcaster) {
|
if (_broadcaster) {
|
||||||
_broadcaster.broadcast(jobId, '', 'job-failed', { jobId, error });
|
_broadcaster.broadcast(jobId, getRepositoryIdForJob(jobId), 'job-failed', {
|
||||||
|
jobId,
|
||||||
|
status: 'failed',
|
||||||
|
error
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEmbedDone: (jobId: string) => {
|
onEmbedDone: (jobId: string) => {
|
||||||
@@ -142,6 +159,9 @@ export function initializePipeline(
|
|||||||
},
|
},
|
||||||
onEmbedFailed: (jobId: string, error: string) => {
|
onEmbedFailed: (jobId: string, error: string) => {
|
||||||
console.error('[WorkerPool] Embedding failed for job:', jobId, error);
|
console.error('[WorkerPool] Embedding failed for job:', jobId, error);
|
||||||
|
},
|
||||||
|
onWorkerStatus: (status) => {
|
||||||
|
_broadcaster?.broadcastWorkerStatus(status);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,5 +228,3 @@ export function _resetSingletons(): void {
|
|||||||
_pool = null;
|
_pool = null;
|
||||||
_broadcaster = null;
|
_broadcaster = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,175 @@ import { crawl as githubCrawl } from '$lib/server/crawler/github.crawler.js';
|
|||||||
import { LocalCrawler } from '$lib/server/crawler/local.crawler.js';
|
import { LocalCrawler } from '$lib/server/crawler/local.crawler.js';
|
||||||
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
|
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
|
||||||
import { IndexingJobEntity, type IndexingJobEntityProps } from '$lib/server/models/indexing-job.js';
|
import { IndexingJobEntity, type IndexingJobEntityProps } from '$lib/server/models/indexing-job.js';
|
||||||
import type { ParseWorkerRequest, ParseWorkerResponse, WorkerInitData } from './worker-types.js';
|
import { applySqlitePragmas } from '$lib/server/db/connection.js';
|
||||||
|
import type {
|
||||||
|
ParseWorkerRequest,
|
||||||
|
ParseWorkerResponse,
|
||||||
|
SerializedDocument,
|
||||||
|
SerializedSnippet,
|
||||||
|
WorkerInitData
|
||||||
|
} from './worker-types.js';
|
||||||
import type { IndexingStage } from '$lib/types.js';
|
import type { IndexingStage } from '$lib/types.js';
|
||||||
|
|
||||||
const { dbPath } = workerData as WorkerInitData;
|
const { dbPath } = workerData as WorkerInitData;
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
db.pragma('journal_mode = WAL');
|
applySqlitePragmas(db);
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
db.pragma('busy_timeout = 5000');
|
|
||||||
|
|
||||||
const pipeline = new IndexingPipeline(db, githubCrawl, new LocalCrawler(), null);
|
let pendingWrite: {
|
||||||
|
jobId: string;
|
||||||
|
resolve: () => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
function serializeDocument(document: {
|
||||||
|
id: string;
|
||||||
|
repositoryId: string;
|
||||||
|
versionId?: string | null;
|
||||||
|
filePath: string;
|
||||||
|
title?: string | null;
|
||||||
|
language?: string | null;
|
||||||
|
tokenCount?: number | null;
|
||||||
|
checksum: string;
|
||||||
|
indexedAt: Date;
|
||||||
|
}): SerializedDocument {
|
||||||
|
return {
|
||||||
|
id: document.id,
|
||||||
|
repositoryId: document.repositoryId,
|
||||||
|
versionId: document.versionId ?? null,
|
||||||
|
filePath: document.filePath,
|
||||||
|
title: document.title ?? null,
|
||||||
|
language: document.language ?? null,
|
||||||
|
tokenCount: document.tokenCount ?? 0,
|
||||||
|
checksum: document.checksum,
|
||||||
|
indexedAt: Math.floor(document.indexedAt.getTime() / 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeSnippet(snippet: {
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
repositoryId: string;
|
||||||
|
versionId?: string | null;
|
||||||
|
type: 'code' | 'info';
|
||||||
|
title?: string | null;
|
||||||
|
content: string;
|
||||||
|
language?: string | null;
|
||||||
|
breadcrumb?: string | null;
|
||||||
|
tokenCount?: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): SerializedSnippet {
|
||||||
|
return {
|
||||||
|
id: snippet.id,
|
||||||
|
documentId: snippet.documentId,
|
||||||
|
repositoryId: snippet.repositoryId,
|
||||||
|
versionId: snippet.versionId ?? null,
|
||||||
|
type: snippet.type,
|
||||||
|
title: snippet.title ?? null,
|
||||||
|
content: snippet.content,
|
||||||
|
language: snippet.language ?? null,
|
||||||
|
breadcrumb: snippet.breadcrumb ?? null,
|
||||||
|
tokenCount: snippet.tokenCount ?? 0,
|
||||||
|
createdAt: Math.floor(snippet.createdAt.getTime() / 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWrite(
|
||||||
|
message: Extract<
|
||||||
|
ParseWorkerResponse,
|
||||||
|
{
|
||||||
|
type:
|
||||||
|
| 'write_replace'
|
||||||
|
| 'write_clone'
|
||||||
|
| 'write_repo_update'
|
||||||
|
| 'write_version_update'
|
||||||
|
| 'write_repo_config';
|
||||||
|
}
|
||||||
|
>
|
||||||
|
): Promise<void> {
|
||||||
|
if (pendingWrite) {
|
||||||
|
return Promise.reject(new Error(`write request already in flight for ${pendingWrite.jobId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingWrite = {
|
||||||
|
jobId: message.jobId,
|
||||||
|
resolve: () => {
|
||||||
|
pendingWrite = null;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
reject: (error: Error) => {
|
||||||
|
pendingWrite = null;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
parentPort!.postMessage(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = new IndexingPipeline(db, githubCrawl, new LocalCrawler(), null, {
|
||||||
|
persistJobUpdates: false,
|
||||||
|
replaceSnippets: async (changedDocIds, newDocuments, newSnippets) => {
|
||||||
|
await requestWrite({
|
||||||
|
type: 'write_replace',
|
||||||
|
jobId: currentJobId ?? 'unknown',
|
||||||
|
changedDocIds,
|
||||||
|
documents: newDocuments.map(serializeDocument),
|
||||||
|
snippets: newSnippets.map(serializeSnippet)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cloneFromAncestor: async (request) => {
|
||||||
|
await requestWrite({
|
||||||
|
type: 'write_clone',
|
||||||
|
jobId: currentJobId ?? 'unknown',
|
||||||
|
ancestorVersionId: request.ancestorVersionId,
|
||||||
|
targetVersionId: request.targetVersionId,
|
||||||
|
repositoryId: request.repositoryId,
|
||||||
|
unchangedPaths: request.unchangedPaths
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateRepo: async (repositoryId, fields) => {
|
||||||
|
await requestWrite({
|
||||||
|
type: 'write_repo_update',
|
||||||
|
jobId: currentJobId ?? 'unknown',
|
||||||
|
repositoryId,
|
||||||
|
fields
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateVersion: async (versionId, fields) => {
|
||||||
|
await requestWrite({
|
||||||
|
type: 'write_version_update',
|
||||||
|
jobId: currentJobId ?? 'unknown',
|
||||||
|
versionId,
|
||||||
|
fields
|
||||||
|
});
|
||||||
|
},
|
||||||
|
upsertRepoConfig: async (repositoryId, versionId, rules) => {
|
||||||
|
await requestWrite({
|
||||||
|
type: 'write_repo_config',
|
||||||
|
jobId: currentJobId ?? 'unknown',
|
||||||
|
repositoryId,
|
||||||
|
versionId,
|
||||||
|
rules
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
let currentJobId: string | null = null;
|
let currentJobId: string | null = null;
|
||||||
|
|
||||||
parentPort!.on('message', async (msg: ParseWorkerRequest) => {
|
parentPort!.on('message', async (msg: ParseWorkerRequest) => {
|
||||||
|
if (msg.type === 'write_ack') {
|
||||||
|
if (pendingWrite?.jobId === msg.jobId) {
|
||||||
|
pendingWrite.resolve();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'write_error') {
|
||||||
|
if (pendingWrite?.jobId === msg.jobId) {
|
||||||
|
pendingWrite.reject(new Error(msg.error));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'shutdown') {
|
if (msg.type === 'shutdown') {
|
||||||
db.close();
|
db.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -30,11 +186,19 @@ parentPort!.on('message', async (msg: ParseWorkerRequest) => {
|
|||||||
if (!rawJob) {
|
if (!rawJob) {
|
||||||
throw new Error(`Job ${msg.jobId} not found`);
|
throw new Error(`Job ${msg.jobId} not found`);
|
||||||
}
|
}
|
||||||
const job = IndexingJobMapper.fromEntity(new IndexingJobEntity(rawJob as IndexingJobEntityProps));
|
const job = IndexingJobMapper.fromEntity(
|
||||||
|
new IndexingJobEntity(rawJob as IndexingJobEntityProps)
|
||||||
|
);
|
||||||
|
|
||||||
await pipeline.run(
|
await pipeline.run(
|
||||||
job,
|
job,
|
||||||
(stage: IndexingStage, detail?: string, progress?: number, processedFiles?: number, totalFiles?: number) => {
|
(
|
||||||
|
stage: IndexingStage,
|
||||||
|
detail?: string,
|
||||||
|
progress?: number,
|
||||||
|
processedFiles?: number,
|
||||||
|
totalFiles?: number
|
||||||
|
) => {
|
||||||
parentPort!.postMessage({
|
parentPort!.postMessage({
|
||||||
type: 'progress',
|
type: 'progress',
|
||||||
jobId: msg.jobId,
|
jobId: msg.jobId,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { writeFileSync, unlinkSync, existsSync } from 'node:fs';
|
import { writeFileSync, unlinkSync, existsSync } from 'node:fs';
|
||||||
import { EventEmitter } from 'node:events';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Hoist FakeWorker + registry so vi.mock can reference them.
|
// Hoist FakeWorker + registry so vi.mock can reference them.
|
||||||
@@ -36,7 +35,7 @@ const { createdWorkers, FakeWorker } = vi.hoisted(() => {
|
|||||||
this.threadId = 0;
|
this.threadId = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor(_script: string, _opts?: unknown) {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
createdWorkers.push(this);
|
createdWorkers.push(this);
|
||||||
}
|
}
|
||||||
@@ -67,6 +66,7 @@ function makeOpts(overrides: Partial<WorkerPoolOptions> = {}): WorkerPoolOptions
|
|||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
workerScript: FAKE_SCRIPT,
|
workerScript: FAKE_SCRIPT,
|
||||||
embedWorkerScript: MISSING_SCRIPT,
|
embedWorkerScript: MISSING_SCRIPT,
|
||||||
|
writeWorkerScript: MISSING_SCRIPT,
|
||||||
dbPath: ':memory:',
|
dbPath: ':memory:',
|
||||||
onProgress: vi.fn(),
|
onProgress: vi.fn(),
|
||||||
onJobDone: vi.fn(),
|
onJobDone: vi.fn(),
|
||||||
@@ -142,6 +142,12 @@ describe('WorkerPool normal mode', () => {
|
|||||||
expect(createdWorkers).toHaveLength(3);
|
expect(createdWorkers).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('spawns a write worker when writeWorkerScript exists', () => {
|
||||||
|
new WorkerPool(makeOpts({ concurrency: 2, writeWorkerScript: FAKE_SCRIPT }));
|
||||||
|
|
||||||
|
expect(createdWorkers).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// enqueue dispatches to an idle worker
|
// enqueue dispatches to an idle worker
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -208,8 +214,12 @@ describe('WorkerPool normal mode', () => {
|
|||||||
const runCalls = createdWorkers.flatMap((w) =>
|
const runCalls = createdWorkers.flatMap((w) =>
|
||||||
w.postMessage.mock.calls.filter((c) => (c[0] as { type: string })?.type === 'run')
|
w.postMessage.mock.calls.filter((c) => (c[0] as { type: string })?.type === 'run')
|
||||||
);
|
);
|
||||||
expect(runCalls.filter((c) => (c[0] as unknown as { jobId: string }).jobId === 'job-1')).toHaveLength(1);
|
expect(
|
||||||
expect(runCalls.filter((c) => (c[0] as unknown as { jobId: string }).jobId === 'job-2')).toHaveLength(0);
|
runCalls.filter((c) => (c[0] as unknown as { jobId: string }).jobId === 'job-1')
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
runCalls.filter((c) => (c[0] as unknown as { jobId: string }).jobId === 'job-2')
|
||||||
|
).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts jobs for different repos concurrently', () => {
|
it('starts jobs for different repos concurrently', () => {
|
||||||
@@ -227,6 +237,83 @@ describe('WorkerPool normal mode', () => {
|
|||||||
expect(dispatchedIds).toContain('job-beta');
|
expect(dispatchedIds).toContain('job-beta');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dispatches same-repo jobs concurrently when versionIds differ', () => {
|
||||||
|
const pool = new WorkerPool(makeOpts({ concurrency: 2 }));
|
||||||
|
|
||||||
|
pool.enqueue('job-v1', '/repo/same', 'v1');
|
||||||
|
pool.enqueue('job-v2', '/repo/same', 'v2');
|
||||||
|
|
||||||
|
const runCalls = createdWorkers.flatMap((w) =>
|
||||||
|
w.postMessage.mock.calls.filter((c) => (c[0] as { type: string })?.type === 'run')
|
||||||
|
);
|
||||||
|
const dispatchedIds = runCalls.map((c) => (c[0] as unknown as { jobId: string }).jobId);
|
||||||
|
expect(dispatchedIds).toContain('job-v1');
|
||||||
|
expect(dispatchedIds).toContain('job-v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards write worker acknowledgements back to the originating parse worker', () => {
|
||||||
|
new WorkerPool(makeOpts({ concurrency: 1, writeWorkerScript: FAKE_SCRIPT }));
|
||||||
|
const parseWorker = createdWorkers[0];
|
||||||
|
const writeWorker = createdWorkers[1];
|
||||||
|
writeWorker.emit('message', { type: 'ready' });
|
||||||
|
|
||||||
|
parseWorker.emit('message', {
|
||||||
|
type: 'write_replace',
|
||||||
|
jobId: 'job-write',
|
||||||
|
changedDocIds: [],
|
||||||
|
documents: [],
|
||||||
|
snippets: []
|
||||||
|
});
|
||||||
|
writeWorker.emit('message', { type: 'write_ack', jobId: 'job-write' });
|
||||||
|
|
||||||
|
expect(writeWorker.postMessage).toHaveBeenCalledWith({
|
||||||
|
type: 'write_replace',
|
||||||
|
jobId: 'job-write',
|
||||||
|
changedDocIds: [],
|
||||||
|
documents: [],
|
||||||
|
snippets: []
|
||||||
|
});
|
||||||
|
expect(parseWorker.postMessage).toHaveBeenCalledWith({ type: 'write_ack', jobId: 'job-write' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards write worker acknowledgements back to the embed worker', () => {
|
||||||
|
new WorkerPool(
|
||||||
|
makeOpts({
|
||||||
|
concurrency: 1,
|
||||||
|
writeWorkerScript: FAKE_SCRIPT,
|
||||||
|
embedWorkerScript: FAKE_SCRIPT,
|
||||||
|
embeddingProfileId: 'local-default'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const parseWorker = createdWorkers[0];
|
||||||
|
const embedWorker = createdWorkers[1];
|
||||||
|
const writeWorker = createdWorkers[2];
|
||||||
|
writeWorker.emit('message', { type: 'ready' });
|
||||||
|
embedWorker.emit('message', { type: 'ready' });
|
||||||
|
|
||||||
|
embedWorker.emit('message', {
|
||||||
|
type: 'write_embeddings',
|
||||||
|
jobId: 'job-embed',
|
||||||
|
embeddings: []
|
||||||
|
});
|
||||||
|
writeWorker.emit('message', { type: 'write_ack', jobId: 'job-embed', embeddingCount: 0 });
|
||||||
|
|
||||||
|
expect(parseWorker.postMessage).not.toHaveBeenCalledWith({
|
||||||
|
type: 'write_ack',
|
||||||
|
jobId: 'job-embed'
|
||||||
|
});
|
||||||
|
expect(writeWorker.postMessage).toHaveBeenCalledWith({
|
||||||
|
type: 'write_embeddings',
|
||||||
|
jobId: 'job-embed',
|
||||||
|
embeddings: []
|
||||||
|
});
|
||||||
|
expect(embedWorker.postMessage).toHaveBeenCalledWith({
|
||||||
|
type: 'write_ack',
|
||||||
|
jobId: 'job-embed',
|
||||||
|
embeddingCount: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Worker crash (exit code != 0)
|
// Worker crash (exit code != 0)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -248,7 +335,7 @@ describe('WorkerPool normal mode', () => {
|
|||||||
|
|
||||||
it('does NOT call onJobFailed when a worker exits cleanly (code 0)', () => {
|
it('does NOT call onJobFailed when a worker exits cleanly (code 0)', () => {
|
||||||
const opts = makeOpts({ concurrency: 1 });
|
const opts = makeOpts({ concurrency: 1 });
|
||||||
const pool = new WorkerPool(opts);
|
new WorkerPool(opts);
|
||||||
|
|
||||||
// Exit without any running job
|
// Exit without any running job
|
||||||
const worker = createdWorkers[0];
|
const worker = createdWorkers[0];
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import { Worker } from 'node:worker_threads';
|
import { Worker } from 'node:worker_threads';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import type { ParseWorkerRequest, ParseWorkerResponse, EmbedWorkerRequest, EmbedWorkerResponse, WorkerInitData } from './worker-types.js';
|
import type {
|
||||||
|
ParseWorkerRequest,
|
||||||
|
ParseWorkerResponse,
|
||||||
|
EmbedWorkerRequest,
|
||||||
|
EmbedWorkerResponse,
|
||||||
|
WorkerInitData,
|
||||||
|
WriteWorkerRequest,
|
||||||
|
WriteWorkerResponse
|
||||||
|
} from './worker-types.js';
|
||||||
|
|
||||||
|
type InFlightWriteRequest = Exclude<WriteWorkerRequest, { type: 'shutdown' }>;
|
||||||
|
|
||||||
export interface WorkerPoolOptions {
|
export interface WorkerPoolOptions {
|
||||||
concurrency: number;
|
concurrency: number;
|
||||||
workerScript: string;
|
workerScript: string;
|
||||||
embedWorkerScript: string;
|
embedWorkerScript: string;
|
||||||
|
writeWorkerScript?: string;
|
||||||
dbPath: string;
|
dbPath: string;
|
||||||
embeddingProfileId?: string;
|
embeddingProfileId?: string;
|
||||||
onProgress: (jobId: string, msg: Extract<ParseWorkerResponse, { type: 'progress' }>) => void;
|
onProgress: (jobId: string, msg: Extract<ParseWorkerResponse, { type: 'progress' }>) => void;
|
||||||
@@ -13,6 +24,22 @@ export interface WorkerPoolOptions {
|
|||||||
onJobFailed: (jobId: string, error: string) => void;
|
onJobFailed: (jobId: string, error: string) => void;
|
||||||
onEmbedDone: (jobId: string) => void;
|
onEmbedDone: (jobId: string) => void;
|
||||||
onEmbedFailed: (jobId: string, error: string) => void;
|
onEmbedFailed: (jobId: string, error: string) => void;
|
||||||
|
onWorkerStatus?: (status: WorkerPoolStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkerStatusEntry {
|
||||||
|
index: number;
|
||||||
|
state: 'idle' | 'running';
|
||||||
|
jobId: string | null;
|
||||||
|
repositoryId: string | null;
|
||||||
|
versionId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkerPoolStatus {
|
||||||
|
concurrency: number;
|
||||||
|
active: number;
|
||||||
|
idle: number;
|
||||||
|
workers: WorkerStatusEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueuedJob {
|
interface QueuedJob {
|
||||||
@@ -24,6 +51,7 @@ interface QueuedJob {
|
|||||||
interface RunningJob {
|
interface RunningJob {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
|
versionId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmbedQueuedJob {
|
interface EmbedQueuedJob {
|
||||||
@@ -36,11 +64,14 @@ export class WorkerPool {
|
|||||||
private workers: Worker[] = [];
|
private workers: Worker[] = [];
|
||||||
private idleWorkers: Worker[] = [];
|
private idleWorkers: Worker[] = [];
|
||||||
private embedWorker: Worker | null = null;
|
private embedWorker: Worker | null = null;
|
||||||
|
private writeWorker: Worker | null = null;
|
||||||
private embedReady = false;
|
private embedReady = false;
|
||||||
|
private writeReady = false;
|
||||||
private jobQueue: QueuedJob[] = [];
|
private jobQueue: QueuedJob[] = [];
|
||||||
private runningJobs = new Map<Worker, RunningJob>();
|
private runningJobs = new Map<Worker, RunningJob>();
|
||||||
private runningRepoIds = new Set<string>();
|
private runningJobKeys = new Set<string>();
|
||||||
private embedQueue: EmbedQueuedJob[] = [];
|
private embedQueue: EmbedQueuedJob[] = [];
|
||||||
|
private pendingWriteWorkers = new Map<string, Worker>();
|
||||||
private options: WorkerPoolOptions;
|
private options: WorkerPoolOptions;
|
||||||
private fallbackMode = false;
|
private fallbackMode = false;
|
||||||
private shuttingDown = false;
|
private shuttingDown = false;
|
||||||
@@ -66,6 +97,12 @@ export class WorkerPool {
|
|||||||
if (options.embeddingProfileId && existsSync(options.embedWorkerScript)) {
|
if (options.embeddingProfileId && existsSync(options.embedWorkerScript)) {
|
||||||
this.embedWorker = this.spawnEmbedWorker();
|
this.embedWorker = this.spawnEmbedWorker();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.writeWorkerScript && existsSync(options.writeWorkerScript)) {
|
||||||
|
this.writeWorker = this.spawnWriteWorker(options.writeWorkerScript);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitStatusChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private spawnParseWorker(): Worker {
|
private spawnParseWorker(): Worker {
|
||||||
@@ -94,6 +131,22 @@ export class WorkerPool {
|
|||||||
return worker;
|
return worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private spawnWriteWorker(writeWorkerScript: string): Worker {
|
||||||
|
const worker = new Worker(writeWorkerScript, {
|
||||||
|
workerData: {
|
||||||
|
dbPath: this.options.dbPath
|
||||||
|
} satisfies WorkerInitData
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('message', (msg: WriteWorkerResponse) => this.onWriteWorkerMessage(msg));
|
||||||
|
worker.on('exit', () => {
|
||||||
|
this.writeReady = false;
|
||||||
|
this.writeWorker = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
public enqueue(jobId: string, repositoryId: string, versionId?: string | null): void {
|
public enqueue(jobId: string, repositoryId: string, versionId?: string | null): void {
|
||||||
if (this.shuttingDown) {
|
if (this.shuttingDown) {
|
||||||
console.warn('WorkerPool is shutting down, ignoring enqueue request');
|
console.warn('WorkerPool is shutting down, ignoring enqueue request');
|
||||||
@@ -109,10 +162,18 @@ export class WorkerPool {
|
|||||||
this.dispatch();
|
this.dispatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static jobKey(repositoryId: string, versionId?: string | null): string {
|
||||||
|
return `${repositoryId}:${versionId ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
private dispatch(): void {
|
private dispatch(): void {
|
||||||
|
let statusChanged = false;
|
||||||
|
|
||||||
while (this.idleWorkers.length > 0 && this.jobQueue.length > 0) {
|
while (this.idleWorkers.length > 0 && this.jobQueue.length > 0) {
|
||||||
// Find first job whose repositoryId is not currently running
|
// Find first job whose (repositoryId, versionId) compound key is not currently running
|
||||||
const jobIdx = this.jobQueue.findIndex((j) => !this.runningRepoIds.has(j.repositoryId));
|
const jobIdx = this.jobQueue.findIndex(
|
||||||
|
(j) => !this.runningJobKeys.has(WorkerPool.jobKey(j.repositoryId, j.versionId))
|
||||||
|
);
|
||||||
|
|
||||||
if (jobIdx === -1) {
|
if (jobIdx === -1) {
|
||||||
// No eligible job found (all repos have running jobs)
|
// No eligible job found (all repos have running jobs)
|
||||||
@@ -122,41 +183,120 @@ export class WorkerPool {
|
|||||||
const job = this.jobQueue.splice(jobIdx, 1)[0];
|
const job = this.jobQueue.splice(jobIdx, 1)[0];
|
||||||
const worker = this.idleWorkers.pop()!;
|
const worker = this.idleWorkers.pop()!;
|
||||||
|
|
||||||
this.runningJobs.set(worker, { jobId: job.jobId, repositoryId: job.repositoryId });
|
this.runningJobs.set(worker, {
|
||||||
this.runningRepoIds.add(job.repositoryId);
|
jobId: job.jobId,
|
||||||
|
repositoryId: job.repositoryId,
|
||||||
|
versionId: job.versionId
|
||||||
|
});
|
||||||
|
this.runningJobKeys.add(WorkerPool.jobKey(job.repositoryId, job.versionId));
|
||||||
|
statusChanged = true;
|
||||||
|
|
||||||
const msg: ParseWorkerRequest = { type: 'run', jobId: job.jobId };
|
const msg: ParseWorkerRequest = { type: 'run', jobId: job.jobId };
|
||||||
worker.postMessage(msg);
|
worker.postMessage(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (statusChanged) {
|
||||||
|
this.emitStatusChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private postWriteRequest(request: InFlightWriteRequest, worker?: Worker): void {
|
||||||
|
if (!this.writeWorker || !this.writeReady) {
|
||||||
|
if (worker) {
|
||||||
|
worker.postMessage({
|
||||||
|
type: 'write_error',
|
||||||
|
jobId: request.jobId,
|
||||||
|
error: 'Write worker is not ready'
|
||||||
|
} satisfies ParseWorkerRequest);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worker) {
|
||||||
|
this.pendingWriteWorkers.set(request.jobId, worker);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.writeWorker.postMessage(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onWorkerMessage(worker: Worker, msg: ParseWorkerResponse): void {
|
private onWorkerMessage(worker: Worker, msg: ParseWorkerResponse): void {
|
||||||
if (msg.type === 'progress') {
|
if (msg.type === 'progress') {
|
||||||
|
this.postWriteRequest({
|
||||||
|
type: 'write_job_update',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
fields: {
|
||||||
|
status: 'running',
|
||||||
|
startedAt: Math.floor(Date.now() / 1000),
|
||||||
|
stage: msg.stage,
|
||||||
|
stageDetail: msg.stageDetail ?? null,
|
||||||
|
progress: msg.progress,
|
||||||
|
processedFiles: msg.processedFiles,
|
||||||
|
totalFiles: msg.totalFiles
|
||||||
|
}
|
||||||
|
});
|
||||||
this.options.onProgress(msg.jobId, msg);
|
this.options.onProgress(msg.jobId, msg);
|
||||||
|
} else if (
|
||||||
|
msg.type === 'write_replace' ||
|
||||||
|
msg.type === 'write_clone' ||
|
||||||
|
msg.type === 'write_repo_update' ||
|
||||||
|
msg.type === 'write_version_update' ||
|
||||||
|
msg.type === 'write_repo_config'
|
||||||
|
) {
|
||||||
|
this.postWriteRequest(msg, worker);
|
||||||
} else if (msg.type === 'done') {
|
} else if (msg.type === 'done') {
|
||||||
const runningJob = this.runningJobs.get(worker);
|
const runningJob = this.runningJobs.get(worker);
|
||||||
|
this.postWriteRequest({
|
||||||
|
type: 'write_job_update',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
fields: {
|
||||||
|
status: 'done',
|
||||||
|
stage: 'done',
|
||||||
|
progress: 100,
|
||||||
|
completedAt: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
});
|
||||||
if (runningJob) {
|
if (runningJob) {
|
||||||
this.runningJobs.delete(worker);
|
this.runningJobs.delete(worker);
|
||||||
this.runningRepoIds.delete(runningJob.repositoryId);
|
this.runningJobKeys.delete(
|
||||||
|
WorkerPool.jobKey(runningJob.repositoryId, runningJob.versionId)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.idleWorkers.push(worker);
|
this.idleWorkers.push(worker);
|
||||||
this.options.onJobDone(msg.jobId);
|
this.options.onJobDone(msg.jobId);
|
||||||
|
this.emitStatusChanged();
|
||||||
|
|
||||||
// If embedding configured, enqueue embed request
|
// If embedding configured, enqueue embed request
|
||||||
if (this.embedWorker && this.options.embeddingProfileId) {
|
if (this.embedWorker && this.options.embeddingProfileId) {
|
||||||
const runningJobData = runningJob || { jobId: msg.jobId, repositoryId: '' };
|
const runningJobData = runningJob || {
|
||||||
this.enqueueEmbed(msg.jobId, runningJobData.repositoryId, null);
|
jobId: msg.jobId,
|
||||||
|
repositoryId: '',
|
||||||
|
versionId: null
|
||||||
|
};
|
||||||
|
this.enqueueEmbed(msg.jobId, runningJobData.repositoryId, runningJobData.versionId ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dispatch();
|
this.dispatch();
|
||||||
} else if (msg.type === 'failed') {
|
} else if (msg.type === 'failed') {
|
||||||
const runningJob = this.runningJobs.get(worker);
|
const runningJob = this.runningJobs.get(worker);
|
||||||
|
this.postWriteRequest({
|
||||||
|
type: 'write_job_update',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
fields: {
|
||||||
|
status: 'failed',
|
||||||
|
stage: 'failed',
|
||||||
|
error: msg.error,
|
||||||
|
completedAt: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
});
|
||||||
if (runningJob) {
|
if (runningJob) {
|
||||||
this.runningJobs.delete(worker);
|
this.runningJobs.delete(worker);
|
||||||
this.runningRepoIds.delete(runningJob.repositoryId);
|
this.runningJobKeys.delete(
|
||||||
|
WorkerPool.jobKey(runningJob.repositoryId, runningJob.versionId)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.idleWorkers.push(worker);
|
this.idleWorkers.push(worker);
|
||||||
this.options.onJobFailed(msg.jobId, msg.error);
|
this.options.onJobFailed(msg.jobId, msg.error);
|
||||||
|
this.emitStatusChanged();
|
||||||
this.dispatch();
|
this.dispatch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,13 +316,15 @@ export class WorkerPool {
|
|||||||
const runningJob = this.runningJobs.get(worker);
|
const runningJob = this.runningJobs.get(worker);
|
||||||
if (runningJob && code !== 0) {
|
if (runningJob && code !== 0) {
|
||||||
this.runningJobs.delete(worker);
|
this.runningJobs.delete(worker);
|
||||||
this.runningRepoIds.delete(runningJob.repositoryId);
|
this.runningJobKeys.delete(WorkerPool.jobKey(runningJob.repositoryId, runningJob.versionId));
|
||||||
this.options.onJobFailed(runningJob.jobId, `Worker crashed with code ${code}`);
|
this.options.onJobFailed(runningJob.jobId, `Worker crashed with code ${code}`);
|
||||||
} else if (runningJob) {
|
} else if (runningJob) {
|
||||||
this.runningJobs.delete(worker);
|
this.runningJobs.delete(worker);
|
||||||
this.runningRepoIds.delete(runningJob.repositoryId);
|
this.runningJobKeys.delete(WorkerPool.jobKey(runningJob.repositoryId, runningJob.versionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.emitStatusChanged();
|
||||||
|
|
||||||
// Remove from workers array
|
// Remove from workers array
|
||||||
const workerIdx = this.workers.indexOf(worker);
|
const workerIdx = this.workers.indexOf(worker);
|
||||||
if (workerIdx !== -1) {
|
if (workerIdx !== -1) {
|
||||||
@@ -203,6 +345,22 @@ export class WorkerPool {
|
|||||||
this.embedReady = true;
|
this.embedReady = true;
|
||||||
// Process any queued embed requests
|
// Process any queued embed requests
|
||||||
this.processEmbedQueue();
|
this.processEmbedQueue();
|
||||||
|
} else if (msg.type === 'write_embeddings') {
|
||||||
|
const embedWorker = this.embedWorker;
|
||||||
|
if (!embedWorker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.writeWorker || !this.writeReady) {
|
||||||
|
embedWorker.postMessage({
|
||||||
|
type: 'write_error',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
error: 'Write worker is not ready'
|
||||||
|
} satisfies EmbedWorkerRequest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.postWriteRequest(msg, embedWorker);
|
||||||
} else if (msg.type === 'embed-progress') {
|
} else if (msg.type === 'embed-progress') {
|
||||||
// Progress message - could be tracked but not strictly required
|
// Progress message - could be tracked but not strictly required
|
||||||
} else if (msg.type === 'embed-done') {
|
} else if (msg.type === 'embed-done') {
|
||||||
@@ -212,6 +370,23 @@ export class WorkerPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onWriteWorkerMessage(msg: WriteWorkerResponse): void {
|
||||||
|
if (msg.type === 'ready') {
|
||||||
|
this.writeReady = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = this.pendingWriteWorkers.get(msg.jobId);
|
||||||
|
if (worker) {
|
||||||
|
this.pendingWriteWorkers.delete(msg.jobId);
|
||||||
|
worker.postMessage(msg satisfies ParseWorkerRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'write_error') {
|
||||||
|
console.error('[WorkerPool] Write worker failed for job:', msg.jobId, msg.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private processEmbedQueue(): void {
|
private processEmbedQueue(): void {
|
||||||
if (!this.embedWorker || !this.embedReady) {
|
if (!this.embedWorker || !this.embedReady) {
|
||||||
return;
|
return;
|
||||||
@@ -250,6 +425,7 @@ export class WorkerPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setMaxConcurrency(n: number): void {
|
public setMaxConcurrency(n: number): void {
|
||||||
|
this.options.concurrency = n;
|
||||||
const current = this.workers.length;
|
const current = this.workers.length;
|
||||||
|
|
||||||
if (n > current) {
|
if (n > current) {
|
||||||
@@ -274,6 +450,8 @@ export class WorkerPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.emitStatusChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async shutdown(): Promise<void> {
|
public async shutdown(): Promise<void> {
|
||||||
@@ -300,6 +478,14 @@ export class WorkerPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.writeWorker) {
|
||||||
|
try {
|
||||||
|
this.writeWorker.postMessage({ type: 'shutdown' });
|
||||||
|
} catch {
|
||||||
|
// Worker might already be exited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for workers to exit with timeout
|
// Wait for workers to exit with timeout
|
||||||
const timeout = 5000;
|
const timeout = 5000;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -329,9 +515,42 @@ export class WorkerPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.writeWorker) {
|
||||||
|
try {
|
||||||
|
this.writeWorker.terminate();
|
||||||
|
} catch {
|
||||||
|
// Already terminated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.workers = [];
|
this.workers = [];
|
||||||
this.idleWorkers = [];
|
this.idleWorkers = [];
|
||||||
this.embedWorker = null;
|
this.embedWorker = null;
|
||||||
|
this.writeWorker = null;
|
||||||
|
this.pendingWriteWorkers.clear();
|
||||||
|
this.emitStatusChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus(): WorkerPoolStatus {
|
||||||
|
return {
|
||||||
|
concurrency: this.options.concurrency,
|
||||||
|
active: this.runningJobs.size,
|
||||||
|
idle: this.idleWorkers.length,
|
||||||
|
workers: this.workers.map((worker, index) => {
|
||||||
|
const runningJob = this.runningJobs.get(worker);
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
state: runningJob ? 'running' : 'idle',
|
||||||
|
jobId: runningJob?.jobId ?? null,
|
||||||
|
repositoryId: runningJob?.repositoryId ?? null,
|
||||||
|
versionId: runningJob?.versionId ?? null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitStatusChanged(): void {
|
||||||
|
this.options.onWorkerStatus?.(this.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
public get isFallbackMode(): boolean {
|
public get isFallbackMode(): boolean {
|
||||||
|
|||||||
@@ -2,24 +2,173 @@ import type { IndexingStage } from '$lib/types.js';
|
|||||||
|
|
||||||
export type ParseWorkerRequest =
|
export type ParseWorkerRequest =
|
||||||
| { type: 'run'; jobId: string }
|
| { type: 'run'; jobId: string }
|
||||||
|
| { type: 'write_ack'; jobId: string }
|
||||||
|
| { type: 'write_error'; jobId: string; error: string }
|
||||||
| { type: 'shutdown' };
|
| { type: 'shutdown' };
|
||||||
|
|
||||||
export type ParseWorkerResponse =
|
export type ParseWorkerResponse =
|
||||||
| { type: 'progress'; jobId: string; stage: IndexingStage; stageDetail?: string; progress: number; processedFiles: number; totalFiles: number }
|
| {
|
||||||
|
type: 'progress';
|
||||||
|
jobId: string;
|
||||||
|
stage: IndexingStage;
|
||||||
|
stageDetail?: string;
|
||||||
|
progress: number;
|
||||||
|
processedFiles: number;
|
||||||
|
totalFiles: number;
|
||||||
|
}
|
||||||
| { type: 'done'; jobId: string }
|
| { type: 'done'; jobId: string }
|
||||||
| { type: 'failed'; jobId: string; error: string };
|
| { type: 'failed'; jobId: string; error: string }
|
||||||
|
| WriteReplaceRequest
|
||||||
|
| WriteCloneRequest
|
||||||
|
| WriteRepoUpdateRequest
|
||||||
|
| WriteVersionUpdateRequest
|
||||||
|
| WriteRepoConfigRequest;
|
||||||
|
|
||||||
export type EmbedWorkerRequest =
|
export type EmbedWorkerRequest =
|
||||||
| { type: 'embed'; jobId: string; repositoryId: string; versionId: string | null }
|
| { type: 'embed'; jobId: string; repositoryId: string; versionId: string | null }
|
||||||
|
| {
|
||||||
|
type: 'write_ack';
|
||||||
|
jobId: string;
|
||||||
|
documentCount?: number;
|
||||||
|
snippetCount?: number;
|
||||||
|
embeddingCount?: number;
|
||||||
|
}
|
||||||
|
| { type: 'write_error'; jobId: string; error: string }
|
||||||
| { type: 'shutdown' };
|
| { type: 'shutdown' };
|
||||||
|
|
||||||
export type EmbedWorkerResponse =
|
export type EmbedWorkerResponse =
|
||||||
| { type: 'ready' }
|
| { type: 'ready' }
|
||||||
| { type: 'embed-progress'; jobId: string; done: number; total: number }
|
| { type: 'embed-progress'; jobId: string; done: number; total: number }
|
||||||
| { type: 'embed-done'; jobId: string }
|
| { type: 'embed-done'; jobId: string }
|
||||||
| { type: 'embed-failed'; jobId: string; error: string };
|
| { type: 'embed-failed'; jobId: string; error: string }
|
||||||
|
| WriteEmbeddingsRequest;
|
||||||
|
|
||||||
|
export type WriteWorkerRequest =
|
||||||
|
| ReplaceWriteRequest
|
||||||
|
| CloneWriteRequest
|
||||||
|
| JobUpdateWriteRequest
|
||||||
|
| RepoUpdateWriteRequest
|
||||||
|
| VersionUpdateWriteRequest
|
||||||
|
| RepoConfigWriteRequest
|
||||||
|
| EmbeddingsWriteRequest
|
||||||
|
| { type: 'shutdown' };
|
||||||
|
|
||||||
|
export type WriteWorkerResponse = { type: 'ready' } | WriteAck | WriteError;
|
||||||
|
|
||||||
export interface WorkerInitData {
|
export interface WorkerInitData {
|
||||||
dbPath: string;
|
dbPath: string;
|
||||||
embeddingProfileId?: string;
|
embeddingProfileId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write worker message types (Phase 6)
|
||||||
|
export interface SerializedDocument {
|
||||||
|
id: string;
|
||||||
|
repositoryId: string;
|
||||||
|
versionId: string | null;
|
||||||
|
filePath: string;
|
||||||
|
title: string | null;
|
||||||
|
language: string | null;
|
||||||
|
tokenCount: number;
|
||||||
|
checksum: string;
|
||||||
|
indexedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedSnippet {
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
repositoryId: string;
|
||||||
|
versionId: string | null;
|
||||||
|
type: 'code' | 'info';
|
||||||
|
title: string | null;
|
||||||
|
content: string;
|
||||||
|
language: string | null;
|
||||||
|
breadcrumb: string | null;
|
||||||
|
tokenCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedEmbedding {
|
||||||
|
snippetId: string;
|
||||||
|
profileId: string;
|
||||||
|
model: string;
|
||||||
|
dimensions: number;
|
||||||
|
embedding: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SerializedFieldValue = string | number | null;
|
||||||
|
|
||||||
|
export type SerializedFields = Record<string, SerializedFieldValue>;
|
||||||
|
|
||||||
|
export type ReplaceWriteRequest = {
|
||||||
|
type: 'write_replace';
|
||||||
|
jobId: string;
|
||||||
|
changedDocIds: string[];
|
||||||
|
documents: SerializedDocument[];
|
||||||
|
snippets: SerializedSnippet[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CloneWriteRequest = {
|
||||||
|
type: 'write_clone';
|
||||||
|
jobId: string;
|
||||||
|
ancestorVersionId: string;
|
||||||
|
targetVersionId: string;
|
||||||
|
repositoryId: string;
|
||||||
|
unchangedPaths: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WriteReplaceRequest = ReplaceWriteRequest;
|
||||||
|
|
||||||
|
export type WriteCloneRequest = CloneWriteRequest;
|
||||||
|
|
||||||
|
export type EmbeddingsWriteRequest = {
|
||||||
|
type: 'write_embeddings';
|
||||||
|
jobId: string;
|
||||||
|
embeddings: SerializedEmbedding[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoUpdateWriteRequest = {
|
||||||
|
type: 'write_repo_update';
|
||||||
|
jobId: string;
|
||||||
|
repositoryId: string;
|
||||||
|
fields: SerializedFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VersionUpdateWriteRequest = {
|
||||||
|
type: 'write_version_update';
|
||||||
|
jobId: string;
|
||||||
|
versionId: string;
|
||||||
|
fields: SerializedFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoConfigWriteRequest = {
|
||||||
|
type: 'write_repo_config';
|
||||||
|
jobId: string;
|
||||||
|
repositoryId: string;
|
||||||
|
versionId: string | null;
|
||||||
|
rules: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JobUpdateWriteRequest = {
|
||||||
|
type: 'write_job_update';
|
||||||
|
jobId: string;
|
||||||
|
fields: SerializedFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WriteEmbeddingsRequest = EmbeddingsWriteRequest;
|
||||||
|
export type WriteRepoUpdateRequest = RepoUpdateWriteRequest;
|
||||||
|
export type WriteVersionUpdateRequest = VersionUpdateWriteRequest;
|
||||||
|
export type WriteRepoConfigRequest = RepoConfigWriteRequest;
|
||||||
|
|
||||||
|
export type WriteAck = {
|
||||||
|
type: 'write_ack';
|
||||||
|
jobId: string;
|
||||||
|
documentCount?: number;
|
||||||
|
snippetCount?: number;
|
||||||
|
embeddingCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WriteError = {
|
||||||
|
type: 'write_error';
|
||||||
|
jobId: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|||||||
343
src/lib/server/pipeline/write-operations.ts
Normal file
343
src/lib/server/pipeline/write-operations.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import type { NewDocument, NewSnippet } from '$lib/types';
|
||||||
|
import { SqliteVecStore } from '$lib/server/search/sqlite-vec.store.js';
|
||||||
|
import type {
|
||||||
|
SerializedDocument,
|
||||||
|
SerializedEmbedding,
|
||||||
|
SerializedFields,
|
||||||
|
SerializedSnippet
|
||||||
|
} from './worker-types.js';
|
||||||
|
|
||||||
|
type DocumentLike = Pick<
|
||||||
|
NewDocument,
|
||||||
|
| 'id'
|
||||||
|
| 'repositoryId'
|
||||||
|
| 'versionId'
|
||||||
|
| 'filePath'
|
||||||
|
| 'title'
|
||||||
|
| 'language'
|
||||||
|
| 'tokenCount'
|
||||||
|
| 'checksum'
|
||||||
|
> & {
|
||||||
|
indexedAt: Date | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SnippetLike = Pick<
|
||||||
|
NewSnippet,
|
||||||
|
| 'id'
|
||||||
|
| 'documentId'
|
||||||
|
| 'repositoryId'
|
||||||
|
| 'versionId'
|
||||||
|
| 'type'
|
||||||
|
| 'title'
|
||||||
|
| 'content'
|
||||||
|
| 'language'
|
||||||
|
| 'breadcrumb'
|
||||||
|
| 'tokenCount'
|
||||||
|
> & {
|
||||||
|
createdAt: Date | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CloneFromAncestorRequest {
|
||||||
|
ancestorVersionId: string;
|
||||||
|
targetVersionId: string;
|
||||||
|
repositoryId: string;
|
||||||
|
unchangedPaths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistedEmbedding {
|
||||||
|
snippetId: string;
|
||||||
|
profileId: string;
|
||||||
|
model: string;
|
||||||
|
dimensions: number;
|
||||||
|
embedding: Buffer | Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEpochSeconds(value: Date | number): number {
|
||||||
|
return value instanceof Date ? Math.floor(value.getTime() / 1000) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSnake(key: string): string {
|
||||||
|
return key.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSnippetsInternal(
|
||||||
|
db: Database.Database,
|
||||||
|
changedDocIds: string[],
|
||||||
|
newDocuments: DocumentLike[],
|
||||||
|
newSnippets: SnippetLike[]
|
||||||
|
): void {
|
||||||
|
const sqliteVecStore = new SqliteVecStore(db);
|
||||||
|
const insertDoc = db.prepare(
|
||||||
|
`INSERT INTO documents
|
||||||
|
(id, repository_id, version_id, file_path, title, language,
|
||||||
|
token_count, checksum, indexed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertSnippet = db.prepare(
|
||||||
|
`INSERT INTO snippets
|
||||||
|
(id, document_id, repository_id, version_id, type, title,
|
||||||
|
content, language, breadcrumb, token_count, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
sqliteVecStore.deleteEmbeddingsForDocumentIds(changedDocIds);
|
||||||
|
|
||||||
|
if (changedDocIds.length > 0) {
|
||||||
|
const placeholders = changedDocIds.map(() => '?').join(',');
|
||||||
|
db.prepare(`DELETE FROM documents WHERE id IN (${placeholders})`).run(...changedDocIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const doc of newDocuments) {
|
||||||
|
insertDoc.run(
|
||||||
|
doc.id,
|
||||||
|
doc.repositoryId,
|
||||||
|
doc.versionId ?? null,
|
||||||
|
doc.filePath,
|
||||||
|
doc.title ?? null,
|
||||||
|
doc.language ?? null,
|
||||||
|
doc.tokenCount ?? 0,
|
||||||
|
doc.checksum,
|
||||||
|
toEpochSeconds(doc.indexedAt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const snippet of newSnippets) {
|
||||||
|
insertSnippet.run(
|
||||||
|
snippet.id,
|
||||||
|
snippet.documentId,
|
||||||
|
snippet.repositoryId,
|
||||||
|
snippet.versionId ?? null,
|
||||||
|
snippet.type,
|
||||||
|
snippet.title ?? null,
|
||||||
|
snippet.content,
|
||||||
|
snippet.language ?? null,
|
||||||
|
snippet.breadcrumb ?? null,
|
||||||
|
snippet.tokenCount ?? 0,
|
||||||
|
toEpochSeconds(snippet.createdAt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceSnippets(
|
||||||
|
db: Database.Database,
|
||||||
|
changedDocIds: string[],
|
||||||
|
newDocuments: NewDocument[],
|
||||||
|
newSnippets: NewSnippet[]
|
||||||
|
): void {
|
||||||
|
replaceSnippetsInternal(db, changedDocIds, newDocuments, newSnippets);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceSerializedSnippets(
|
||||||
|
db: Database.Database,
|
||||||
|
changedDocIds: string[],
|
||||||
|
documents: SerializedDocument[],
|
||||||
|
snippets: SerializedSnippet[]
|
||||||
|
): void {
|
||||||
|
replaceSnippetsInternal(db, changedDocIds, documents, snippets);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneFromAncestor(db: Database.Database, request: CloneFromAncestorRequest): void {
|
||||||
|
const sqliteVecStore = new SqliteVecStore(db);
|
||||||
|
const { ancestorVersionId, targetVersionId, repositoryId, unchangedPaths } = request;
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
const pathList = [...unchangedPaths];
|
||||||
|
if (pathList.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = pathList.map(() => '?').join(',');
|
||||||
|
const ancestorDocs = db
|
||||||
|
.prepare(`SELECT * FROM documents WHERE version_id = ? AND file_path IN (${placeholders})`)
|
||||||
|
.all(ancestorVersionId, ...pathList) as Array<{
|
||||||
|
id: string;
|
||||||
|
repository_id: string;
|
||||||
|
file_path: string;
|
||||||
|
title: string | null;
|
||||||
|
language: string | null;
|
||||||
|
token_count: number;
|
||||||
|
checksum: string;
|
||||||
|
indexed_at: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const docIdMap = new Map<string, string>();
|
||||||
|
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
for (const doc of ancestorDocs) {
|
||||||
|
const newDocId = randomUUID();
|
||||||
|
docIdMap.set(doc.id, newDocId);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO documents (id, repository_id, version_id, file_path, title, language, token_count, checksum, indexed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
newDocId,
|
||||||
|
repositoryId,
|
||||||
|
targetVersionId,
|
||||||
|
doc.file_path,
|
||||||
|
doc.title,
|
||||||
|
doc.language,
|
||||||
|
doc.token_count,
|
||||||
|
doc.checksum,
|
||||||
|
nowEpoch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (docIdMap.size === 0) return;
|
||||||
|
|
||||||
|
const oldDocIds = [...docIdMap.keys()];
|
||||||
|
const snippetPlaceholders = oldDocIds.map(() => '?').join(',');
|
||||||
|
const ancestorSnippets = db
|
||||||
|
.prepare(`SELECT * FROM snippets WHERE document_id IN (${snippetPlaceholders})`)
|
||||||
|
.all(...oldDocIds) as Array<{
|
||||||
|
id: string;
|
||||||
|
document_id: string;
|
||||||
|
repository_id: string;
|
||||||
|
version_id: string | null;
|
||||||
|
type: string;
|
||||||
|
title: string | null;
|
||||||
|
content: string;
|
||||||
|
language: string | null;
|
||||||
|
breadcrumb: string | null;
|
||||||
|
token_count: number;
|
||||||
|
created_at: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const snippetIdMap = new Map<string, string>();
|
||||||
|
for (const snippet of ancestorSnippets) {
|
||||||
|
const newSnippetId = randomUUID();
|
||||||
|
snippetIdMap.set(snippet.id, newSnippetId);
|
||||||
|
const newDocId = docIdMap.get(snippet.document_id)!;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO snippets (id, document_id, repository_id, version_id, type, title, content, language, breadcrumb, token_count, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
newSnippetId,
|
||||||
|
newDocId,
|
||||||
|
repositoryId,
|
||||||
|
targetVersionId,
|
||||||
|
snippet.type,
|
||||||
|
snippet.title,
|
||||||
|
snippet.content,
|
||||||
|
snippet.language,
|
||||||
|
snippet.breadcrumb,
|
||||||
|
snippet.token_count,
|
||||||
|
snippet.created_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snippetIdMap.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldSnippetIds = [...snippetIdMap.keys()];
|
||||||
|
const embPlaceholders = oldSnippetIds.map(() => '?').join(',');
|
||||||
|
const ancestorEmbeddings = db
|
||||||
|
.prepare(`SELECT * FROM snippet_embeddings WHERE snippet_id IN (${embPlaceholders})`)
|
||||||
|
.all(...oldSnippetIds) as Array<{
|
||||||
|
snippet_id: string;
|
||||||
|
profile_id: string;
|
||||||
|
model: string;
|
||||||
|
dimensions: number;
|
||||||
|
embedding: Buffer;
|
||||||
|
created_at: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
for (const emb of ancestorEmbeddings) {
|
||||||
|
const newSnippetId = snippetIdMap.get(emb.snippet_id)!;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(newSnippetId, emb.profile_id, emb.model, emb.dimensions, emb.embedding, emb.created_at);
|
||||||
|
sqliteVecStore.upsertEmbeddingBuffer(
|
||||||
|
emb.profile_id,
|
||||||
|
newSnippetId,
|
||||||
|
emb.embedding,
|
||||||
|
emb.dimensions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertEmbeddings(db: Database.Database, embeddings: PersistedEmbedding[]): void {
|
||||||
|
if (embeddings.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqliteVecStore = new SqliteVecStore(db);
|
||||||
|
const insert = db.prepare<[string, string, string, number, Buffer]>(`
|
||||||
|
INSERT OR REPLACE INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, unixepoch())
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const item of embeddings) {
|
||||||
|
const embeddingBuffer = Buffer.isBuffer(item.embedding)
|
||||||
|
? item.embedding
|
||||||
|
: Buffer.from(item.embedding);
|
||||||
|
|
||||||
|
insert.run(item.snippetId, item.profileId, item.model, item.dimensions, embeddingBuffer);
|
||||||
|
|
||||||
|
sqliteVecStore.upsertEmbeddingBuffer(
|
||||||
|
item.profileId,
|
||||||
|
item.snippetId,
|
||||||
|
embeddingBuffer,
|
||||||
|
item.dimensions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertSerializedEmbeddings(
|
||||||
|
db: Database.Database,
|
||||||
|
embeddings: SerializedEmbedding[]
|
||||||
|
): void {
|
||||||
|
upsertEmbeddings(
|
||||||
|
db,
|
||||||
|
embeddings.map((item) => ({
|
||||||
|
snippetId: item.snippetId,
|
||||||
|
profileId: item.profileId,
|
||||||
|
model: item.model,
|
||||||
|
dimensions: item.dimensions,
|
||||||
|
embedding: item.embedding
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRepo(
|
||||||
|
db: Database.Database,
|
||||||
|
repositoryId: string,
|
||||||
|
fields: SerializedFields
|
||||||
|
): void {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const allFields = { ...fields, updatedAt: now };
|
||||||
|
const sets = Object.keys(allFields)
|
||||||
|
.map((key) => `${toSnake(key)} = ?`)
|
||||||
|
.join(', ');
|
||||||
|
const values = [...Object.values(allFields), repositoryId];
|
||||||
|
db.prepare(`UPDATE repositories SET ${sets} WHERE id = ?`).run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateJob(db: Database.Database, jobId: string, fields: SerializedFields): void {
|
||||||
|
const sets = Object.keys(fields)
|
||||||
|
.map((key) => `${toSnake(key)} = ?`)
|
||||||
|
.join(', ');
|
||||||
|
const values = [...Object.values(fields), jobId];
|
||||||
|
db.prepare(`UPDATE indexing_jobs SET ${sets} WHERE id = ?`).run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateVersion(
|
||||||
|
db: Database.Database,
|
||||||
|
versionId: string,
|
||||||
|
fields: SerializedFields
|
||||||
|
): void {
|
||||||
|
const sets = Object.keys(fields)
|
||||||
|
.map((key) => `${toSnake(key)} = ?`)
|
||||||
|
.join(', ');
|
||||||
|
const values = [...Object.values(fields), versionId];
|
||||||
|
db.prepare(`UPDATE repository_versions SET ${sets} WHERE id = ?`).run(...values);
|
||||||
|
}
|
||||||
169
src/lib/server/pipeline/write-worker-entry.ts
Normal file
169
src/lib/server/pipeline/write-worker-entry.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { workerData, parentPort } from 'node:worker_threads';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { applySqlitePragmas } from '$lib/server/db/connection.js';
|
||||||
|
import { loadSqliteVec } from '$lib/server/db/sqlite-vec.js';
|
||||||
|
import type { WorkerInitData, WriteWorkerRequest, WriteWorkerResponse } from './worker-types.js';
|
||||||
|
import {
|
||||||
|
cloneFromAncestor,
|
||||||
|
replaceSerializedSnippets,
|
||||||
|
updateJob,
|
||||||
|
updateRepo,
|
||||||
|
updateVersion,
|
||||||
|
upsertSerializedEmbeddings
|
||||||
|
} from './write-operations.js';
|
||||||
|
|
||||||
|
const { dbPath } = workerData as WorkerInitData;
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
applySqlitePragmas(db);
|
||||||
|
loadSqliteVec(db);
|
||||||
|
|
||||||
|
parentPort?.postMessage({ type: 'ready' } satisfies WriteWorkerResponse);
|
||||||
|
|
||||||
|
parentPort?.on('message', (msg: WriteWorkerRequest) => {
|
||||||
|
if (msg.type === 'shutdown') {
|
||||||
|
db.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'write_replace') {
|
||||||
|
try {
|
||||||
|
replaceSerializedSnippets(db, msg.changedDocIds, msg.documents, msg.snippets);
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_ack',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
documentCount: msg.documents.length,
|
||||||
|
snippetCount: msg.snippets.length
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
} catch (error) {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_error',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'write_clone') {
|
||||||
|
try {
|
||||||
|
cloneFromAncestor(db, {
|
||||||
|
ancestorVersionId: msg.ancestorVersionId,
|
||||||
|
targetVersionId: msg.targetVersionId,
|
||||||
|
repositoryId: msg.repositoryId,
|
||||||
|
unchangedPaths: msg.unchangedPaths
|
||||||
|
});
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_ack',
|
||||||
|
jobId: msg.jobId
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
} catch (error) {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_error',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'write_embeddings') {
|
||||||
|
try {
|
||||||
|
upsertSerializedEmbeddings(db, msg.embeddings);
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_ack',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
embeddingCount: msg.embeddings.length
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
} catch (error) {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_error',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'write_job_update') {
|
||||||
|
try {
|
||||||
|
updateJob(db, msg.jobId, msg.fields);
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_ack',
|
||||||
|
jobId: msg.jobId
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
} catch (error) {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_error',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'write_repo_update') {
|
||||||
|
try {
|
||||||
|
updateRepo(db, msg.repositoryId, msg.fields);
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_ack',
|
||||||
|
jobId: msg.jobId
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
} catch (error) {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_error',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'write_version_update') {
|
||||||
|
try {
|
||||||
|
updateVersion(db, msg.versionId, msg.fields);
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_ack',
|
||||||
|
jobId: msg.jobId
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
} catch (error) {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_error',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'write_repo_config') {
|
||||||
|
try {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (msg.versionId === null) {
|
||||||
|
db.prepare(
|
||||||
|
`DELETE FROM repository_configs WHERE repository_id = ? AND version_id IS NULL`
|
||||||
|
).run(msg.repositoryId);
|
||||||
|
} else {
|
||||||
|
db.prepare(`DELETE FROM repository_configs WHERE repository_id = ? AND version_id = ?`).run(
|
||||||
|
msg.repositoryId,
|
||||||
|
msg.versionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO repository_configs (repository_id, version_id, rules, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)`
|
||||||
|
).run(msg.repositoryId, msg.versionId, JSON.stringify(msg.rules), now);
|
||||||
|
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_ack',
|
||||||
|
jobId: msg.jobId
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
} catch (error) {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'write_error',
|
||||||
|
jobId: msg.jobId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
} satisfies WriteWorkerResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -15,6 +15,8 @@ import { HybridSearchService } from './hybrid.search.service.js';
|
|||||||
import { VectorSearch, cosineSimilarity } from './vector.search.js';
|
import { VectorSearch, cosineSimilarity } from './vector.search.js';
|
||||||
import { reciprocalRankFusion } from './rrf.js';
|
import { reciprocalRankFusion } from './rrf.js';
|
||||||
import type { EmbeddingProvider, EmbeddingVector } from '../embeddings/provider.js';
|
import type { EmbeddingProvider, EmbeddingVector } from '../embeddings/provider.js';
|
||||||
|
import { loadSqliteVec } from '../db/sqlite-vec.js';
|
||||||
|
import { SqliteVecStore } from './sqlite-vec.store.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// In-memory DB factory
|
// In-memory DB factory
|
||||||
@@ -23,6 +25,7 @@ import type { EmbeddingProvider, EmbeddingVector } from '../embeddings/provider.
|
|||||||
function createTestDb(): Database.Database {
|
function createTestDb(): Database.Database {
|
||||||
const client = new Database(':memory:');
|
const client = new Database(':memory:');
|
||||||
client.pragma('foreign_keys = ON');
|
client.pragma('foreign_keys = ON');
|
||||||
|
loadSqliteVec(client);
|
||||||
|
|
||||||
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
||||||
|
|
||||||
@@ -30,7 +33,11 @@ function createTestDb(): Database.Database {
|
|||||||
const migrations = [
|
const migrations = [
|
||||||
'0000_large_master_chief.sql',
|
'0000_large_master_chief.sql',
|
||||||
'0001_quick_nighthawk.sql',
|
'0001_quick_nighthawk.sql',
|
||||||
'0002_silky_stellaris.sql'
|
'0002_silky_stellaris.sql',
|
||||||
|
'0003_multiversion_config.sql',
|
||||||
|
'0004_complete_sentry.sql',
|
||||||
|
'0005_fix_stage_defaults.sql',
|
||||||
|
'0006_yielding_centennial.sql'
|
||||||
];
|
];
|
||||||
for (const migrationFile of migrations) {
|
for (const migrationFile of migrations) {
|
||||||
const migrationSql = readFileSync(join(migrationsFolder, migrationFile), 'utf-8');
|
const migrationSql = readFileSync(join(migrationsFolder, migrationFile), 'utf-8');
|
||||||
@@ -121,6 +128,7 @@ function seedEmbedding(
|
|||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.run(snippetId, profileId, model, values.length, Buffer.from(f32.buffer), NOW_S);
|
.run(snippetId, profileId, model, values.length, Buffer.from(f32.buffer), NOW_S);
|
||||||
|
new SqliteVecStore(client).upsertEmbedding(profileId, snippetId, f32);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -368,6 +376,53 @@ describe('VectorSearch', () => {
|
|||||||
const results = vs.vectorSearch(new Float32Array([-0.5, 0.5]), { repositoryId: repoId });
|
const results = vs.vectorSearch(new Float32Array([-0.5, 0.5]), { repositoryId: repoId });
|
||||||
expect(results[0].score).toBeCloseTo(1.0, 4);
|
expect(results[0].score).toBeCloseTo(1.0, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters by profileId using per-profile vec tables', () => {
|
||||||
|
client
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO embedding_profiles (id, provider_kind, title, enabled, is_default, model, dimensions, config, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
'secondary-profile',
|
||||||
|
'local-transformers',
|
||||||
|
'Secondary',
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
'test-model',
|
||||||
|
2,
|
||||||
|
'{}',
|
||||||
|
NOW_S,
|
||||||
|
NOW_S
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultSnippet = seedSnippet(client, {
|
||||||
|
repositoryId: repoId,
|
||||||
|
documentId: docId,
|
||||||
|
content: 'default profile snippet'
|
||||||
|
});
|
||||||
|
const secondarySnippet = seedSnippet(client, {
|
||||||
|
repositoryId: repoId,
|
||||||
|
documentId: docId,
|
||||||
|
content: 'secondary profile snippet'
|
||||||
|
});
|
||||||
|
|
||||||
|
seedEmbedding(client, defaultSnippet, [1, 0], 'local-default');
|
||||||
|
seedEmbedding(client, secondarySnippet, [1, 0], 'secondary-profile');
|
||||||
|
|
||||||
|
const vs = new VectorSearch(client);
|
||||||
|
const defaultResults = vs.vectorSearch(new Float32Array([1, 0]), {
|
||||||
|
repositoryId: repoId,
|
||||||
|
profileId: 'local-default'
|
||||||
|
});
|
||||||
|
const secondaryResults = vs.vectorSearch(new Float32Array([1, 0]), {
|
||||||
|
repositoryId: repoId,
|
||||||
|
profileId: 'secondary-profile'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(defaultResults.map((result) => result.snippetId)).toEqual([defaultSnippet]);
|
||||||
|
expect(secondaryResults.map((result) => result.snippetId)).toEqual([secondarySnippet]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -148,7 +148,12 @@ export class HybridSearchService {
|
|||||||
|
|
||||||
const topIds = vectorResults.slice(0, limit).map((r) => r.snippetId);
|
const topIds = vectorResults.slice(0, limit).map((r) => r.snippetId);
|
||||||
return {
|
return {
|
||||||
results: this.fetchSnippetsByIds(topIds, options.repositoryId, options.type),
|
results: this.fetchSnippetsByIds(
|
||||||
|
topIds,
|
||||||
|
options.repositoryId,
|
||||||
|
options.versionId,
|
||||||
|
options.type
|
||||||
|
),
|
||||||
searchModeUsed: 'semantic'
|
searchModeUsed: 'semantic'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -194,7 +199,12 @@ export class HybridSearchService {
|
|||||||
|
|
||||||
const topIds = vectorResults.slice(0, limit).map((r) => r.snippetId);
|
const topIds = vectorResults.slice(0, limit).map((r) => r.snippetId);
|
||||||
return {
|
return {
|
||||||
results: this.fetchSnippetsByIds(topIds, options.repositoryId, options.type),
|
results: this.fetchSnippetsByIds(
|
||||||
|
topIds,
|
||||||
|
options.repositoryId,
|
||||||
|
options.versionId,
|
||||||
|
options.type
|
||||||
|
),
|
||||||
searchModeUsed: 'keyword_fallback'
|
searchModeUsed: 'keyword_fallback'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -220,7 +230,12 @@ export class HybridSearchService {
|
|||||||
if (alpha === 1) {
|
if (alpha === 1) {
|
||||||
const topIds = vectorResults.slice(0, limit).map((r) => r.snippetId);
|
const topIds = vectorResults.slice(0, limit).map((r) => r.snippetId);
|
||||||
return {
|
return {
|
||||||
results: this.fetchSnippetsByIds(topIds, options.repositoryId, options.type),
|
results: this.fetchSnippetsByIds(
|
||||||
|
topIds,
|
||||||
|
options.repositoryId,
|
||||||
|
options.versionId,
|
||||||
|
options.type
|
||||||
|
),
|
||||||
searchModeUsed: 'semantic'
|
searchModeUsed: 'semantic'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -234,7 +249,12 @@ export class HybridSearchService {
|
|||||||
|
|
||||||
const topIds = fused.slice(0, limit).map((r) => r.id);
|
const topIds = fused.slice(0, limit).map((r) => r.id);
|
||||||
return {
|
return {
|
||||||
results: this.fetchSnippetsByIds(topIds, options.repositoryId, options.type),
|
results: this.fetchSnippetsByIds(
|
||||||
|
topIds,
|
||||||
|
options.repositoryId,
|
||||||
|
options.versionId,
|
||||||
|
options.type
|
||||||
|
),
|
||||||
searchModeUsed: 'hybrid'
|
searchModeUsed: 'hybrid'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -253,13 +273,19 @@ export class HybridSearchService {
|
|||||||
private fetchSnippetsByIds(
|
private fetchSnippetsByIds(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
repositoryId: string,
|
repositoryId: string,
|
||||||
|
versionId?: string,
|
||||||
type?: 'code' | 'info'
|
type?: 'code' | 'info'
|
||||||
): SnippetSearchResult[] {
|
): SnippetSearchResult[] {
|
||||||
if (ids.length === 0) return [];
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
const placeholders = ids.map(() => '?').join(', ');
|
const placeholders = ids.map(() => '?').join(', ');
|
||||||
const params: unknown[] = [...ids, repositoryId];
|
const params: unknown[] = [...ids, repositoryId];
|
||||||
|
let versionClause = '';
|
||||||
let typeClause = '';
|
let typeClause = '';
|
||||||
|
if (versionId !== undefined) {
|
||||||
|
versionClause = ' AND s.version_id = ?';
|
||||||
|
params.push(versionId);
|
||||||
|
}
|
||||||
if (type !== undefined) {
|
if (type !== undefined) {
|
||||||
typeClause = ' AND s.type = ?';
|
typeClause = ' AND s.type = ?';
|
||||||
params.push(type);
|
params.push(type);
|
||||||
@@ -276,7 +302,7 @@ export class HybridSearchService {
|
|||||||
FROM snippets s
|
FROM snippets s
|
||||||
JOIN repositories r ON r.id = s.repository_id
|
JOIN repositories r ON r.id = s.repository_id
|
||||||
WHERE s.id IN (${placeholders})
|
WHERE s.id IN (${placeholders})
|
||||||
AND s.repository_id = ?${typeClause}`
|
AND s.repository_id = ?${versionClause}${typeClause}`
|
||||||
)
|
)
|
||||||
.all(...params) as RawSnippetById[];
|
.all(...params) as RawSnippetById[];
|
||||||
|
|
||||||
|
|||||||
390
src/lib/server/search/sqlite-vec.store.ts
Normal file
390
src/lib/server/search/sqlite-vec.store.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import {
|
||||||
|
loadSqliteVec,
|
||||||
|
quoteSqliteIdentifier,
|
||||||
|
sqliteVecRowidTableName,
|
||||||
|
sqliteVecTableName
|
||||||
|
} from '$lib/server/db/sqlite-vec.js';
|
||||||
|
|
||||||
|
export interface SqliteVecQueryOptions {
|
||||||
|
repositoryId: string;
|
||||||
|
versionId?: string;
|
||||||
|
profileId?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqliteVecQueryResult {
|
||||||
|
snippetId: string;
|
||||||
|
score: number;
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileDimensionsRow {
|
||||||
|
dimensions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredDimensionsRow {
|
||||||
|
count: number;
|
||||||
|
min_dimensions: number | null;
|
||||||
|
max_dimensions: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnippetRowidRow {
|
||||||
|
rowid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawKnnRow {
|
||||||
|
snippet_id: string;
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CanonicalEmbeddingRow {
|
||||||
|
snippet_id: string;
|
||||||
|
embedding: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredEmbeddingRef {
|
||||||
|
profile_id: string;
|
||||||
|
snippet_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileStoreTables {
|
||||||
|
vectorTableName: string;
|
||||||
|
rowidTableName: string;
|
||||||
|
quotedVectorTableName: string;
|
||||||
|
quotedRowidTableName: string;
|
||||||
|
dimensions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEmbeddingBuffer(values: Float32Array): Buffer {
|
||||||
|
return Buffer.from(values.buffer, values.byteOffset, values.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToScore(distance: number): number {
|
||||||
|
return 1 / (1 + distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SqliteVecStore {
|
||||||
|
constructor(private readonly db: Database.Database) {}
|
||||||
|
|
||||||
|
ensureProfileStore(profileId: string, preferredDimensions?: number): number {
|
||||||
|
const tables = this.getProfileStoreTables(profileId, preferredDimensions);
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${tables.quotedRowidTableName} (
|
||||||
|
rowid INTEGER PRIMARY KEY,
|
||||||
|
snippet_id TEXT NOT NULL UNIQUE REFERENCES snippets(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS ${tables.quotedVectorTableName}
|
||||||
|
USING vec0(embedding float[${tables.dimensions}]);
|
||||||
|
`);
|
||||||
|
|
||||||
|
return tables.dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertEmbedding(profileId: string, snippetId: string, embedding: Float32Array): void {
|
||||||
|
const tables = this.getProfileStoreTables(profileId, embedding.length);
|
||||||
|
|
||||||
|
this.ensureProfileStore(profileId, tables.dimensions);
|
||||||
|
|
||||||
|
const existingRow = this.db
|
||||||
|
.prepare<
|
||||||
|
[string],
|
||||||
|
SnippetRowidRow
|
||||||
|
>(`SELECT rowid FROM ${tables.quotedRowidTableName} WHERE snippet_id = ?`)
|
||||||
|
.get(snippetId);
|
||||||
|
|
||||||
|
const embeddingBuffer = toEmbeddingBuffer(embedding);
|
||||||
|
if (existingRow) {
|
||||||
|
this.db
|
||||||
|
.prepare<
|
||||||
|
[Buffer, number]
|
||||||
|
>(`UPDATE ${tables.quotedVectorTableName} SET embedding = ? WHERE rowid = ?`)
|
||||||
|
.run(embeddingBuffer, existingRow.rowid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertResult = this.db
|
||||||
|
.prepare<[Buffer]>(`INSERT INTO ${tables.quotedVectorTableName} (embedding) VALUES (?)`)
|
||||||
|
.run(embeddingBuffer);
|
||||||
|
this.db
|
||||||
|
.prepare<
|
||||||
|
[number, string]
|
||||||
|
>(`INSERT INTO ${tables.quotedRowidTableName} (rowid, snippet_id) VALUES (?, ?)`)
|
||||||
|
.run(Number(insertResult.lastInsertRowid), snippetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertEmbeddingBuffer(
|
||||||
|
profileId: string,
|
||||||
|
snippetId: string,
|
||||||
|
embedding: Buffer,
|
||||||
|
dimensions?: number
|
||||||
|
): void {
|
||||||
|
const vector = new Float32Array(
|
||||||
|
embedding.buffer,
|
||||||
|
embedding.byteOffset,
|
||||||
|
dimensions ?? Math.floor(embedding.byteLength / Float32Array.BYTES_PER_ELEMENT)
|
||||||
|
);
|
||||||
|
this.upsertEmbedding(profileId, snippetId, vector);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEmbedding(profileId: string, snippetId: string): void {
|
||||||
|
const tables = this.getProfileStoreTables(profileId);
|
||||||
|
this.ensureProfileStore(profileId);
|
||||||
|
|
||||||
|
const existingRow = this.db
|
||||||
|
.prepare<
|
||||||
|
[string],
|
||||||
|
SnippetRowidRow
|
||||||
|
>(`SELECT rowid FROM ${tables.quotedRowidTableName} WHERE snippet_id = ?`)
|
||||||
|
.get(snippetId);
|
||||||
|
|
||||||
|
if (!existingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.db
|
||||||
|
.prepare<[number]>(`DELETE FROM ${tables.quotedVectorTableName} WHERE rowid = ?`)
|
||||||
|
.run(existingRow.rowid);
|
||||||
|
this.db
|
||||||
|
.prepare<[string]>(`DELETE FROM ${tables.quotedRowidTableName} WHERE snippet_id = ?`)
|
||||||
|
.run(snippetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEmbeddingsForDocumentIds(documentIds: string[]): void {
|
||||||
|
if (documentIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = documentIds.map(() => '?').join(', ');
|
||||||
|
const rows = this.db
|
||||||
|
.prepare<unknown[], StoredEmbeddingRef>(
|
||||||
|
`SELECT DISTINCT se.profile_id, se.snippet_id
|
||||||
|
FROM snippet_embeddings se
|
||||||
|
INNER JOIN snippets s ON s.id = se.snippet_id
|
||||||
|
WHERE s.document_id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.all(...documentIds);
|
||||||
|
|
||||||
|
this.deleteEmbeddingRefs(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEmbeddingsForRepository(repositoryId: string): void {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare<[string], StoredEmbeddingRef>(
|
||||||
|
`SELECT DISTINCT se.profile_id, se.snippet_id
|
||||||
|
FROM snippet_embeddings se
|
||||||
|
INNER JOIN snippets s ON s.id = se.snippet_id
|
||||||
|
WHERE s.repository_id = ?`
|
||||||
|
)
|
||||||
|
.all(repositoryId);
|
||||||
|
|
||||||
|
this.deleteEmbeddingRefs(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEmbeddingsForVersion(repositoryId: string, versionId: string): void {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare<[string, string], StoredEmbeddingRef>(
|
||||||
|
`SELECT DISTINCT se.profile_id, se.snippet_id
|
||||||
|
FROM snippet_embeddings se
|
||||||
|
INNER JOIN snippets s ON s.id = se.snippet_id
|
||||||
|
WHERE s.repository_id = ? AND s.version_id = ?`
|
||||||
|
)
|
||||||
|
.all(repositoryId, versionId);
|
||||||
|
|
||||||
|
this.deleteEmbeddingRefs(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryNearestNeighbors(
|
||||||
|
queryEmbedding: Float32Array,
|
||||||
|
options: SqliteVecQueryOptions
|
||||||
|
): SqliteVecQueryResult[] {
|
||||||
|
const { repositoryId, versionId, profileId = 'local-default', limit = 50 } = options;
|
||||||
|
if (limit <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = this.getProfileStoreTables(profileId, queryEmbedding.length);
|
||||||
|
|
||||||
|
this.ensureProfileStore(profileId, tables.dimensions);
|
||||||
|
const totalRows = this.synchronizeProfileStore(profileId, tables);
|
||||||
|
if (totalRows === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT rowids.snippet_id, vec.distance
|
||||||
|
FROM ${tables.quotedVectorTableName} vec
|
||||||
|
JOIN ${tables.quotedRowidTableName} rowids ON rowids.rowid = vec.rowid
|
||||||
|
JOIN snippets s ON s.id = rowids.snippet_id
|
||||||
|
WHERE vec.embedding MATCH ?
|
||||||
|
AND vec.k = ?
|
||||||
|
AND s.repository_id = ?
|
||||||
|
`;
|
||||||
|
const params: unknown[] = [toEmbeddingBuffer(queryEmbedding), totalRows, repositoryId];
|
||||||
|
|
||||||
|
if (versionId !== undefined) {
|
||||||
|
sql += ' AND s.version_id = ?';
|
||||||
|
params.push(versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY vec.distance ASC LIMIT ?';
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
const rows = this.db.prepare<unknown[], RawKnnRow>(sql).all(...params);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
snippetId: row.snippet_id,
|
||||||
|
score: distanceToScore(row.distance),
|
||||||
|
distance: row.distance
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronizeProfileStore(profileId: string, tables: ProfileStoreTables): number {
|
||||||
|
this.db
|
||||||
|
.prepare<[string, number]>(
|
||||||
|
`DELETE FROM ${tables.quotedRowidTableName}
|
||||||
|
WHERE rowid IN (
|
||||||
|
SELECT rowids.rowid
|
||||||
|
FROM ${tables.quotedRowidTableName} rowids
|
||||||
|
LEFT JOIN snippet_embeddings se
|
||||||
|
ON se.snippet_id = rowids.snippet_id
|
||||||
|
AND se.profile_id = ?
|
||||||
|
AND se.dimensions = ?
|
||||||
|
LEFT JOIN ${tables.quotedVectorTableName} vec ON vec.rowid = rowids.rowid
|
||||||
|
WHERE se.snippet_id IS NULL OR vec.rowid IS NULL
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
.run(profileId, tables.dimensions);
|
||||||
|
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM ${tables.quotedVectorTableName}
|
||||||
|
WHERE rowid NOT IN (SELECT rowid FROM ${tables.quotedRowidTableName})`
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const missingRows = this.db
|
||||||
|
.prepare<[string, number], CanonicalEmbeddingRow>(
|
||||||
|
`SELECT se.snippet_id, se.embedding
|
||||||
|
FROM snippet_embeddings se
|
||||||
|
LEFT JOIN ${tables.quotedRowidTableName} rowids ON rowids.snippet_id = se.snippet_id
|
||||||
|
WHERE se.profile_id = ?
|
||||||
|
AND se.dimensions = ?
|
||||||
|
AND rowids.snippet_id IS NULL`
|
||||||
|
)
|
||||||
|
.all(profileId, tables.dimensions);
|
||||||
|
|
||||||
|
if (missingRows.length > 0) {
|
||||||
|
const backfill = this.db.transaction((rows: CanonicalEmbeddingRow[]) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
this.upsertEmbedding(
|
||||||
|
profileId,
|
||||||
|
row.snippet_id,
|
||||||
|
new Float32Array(row.embedding.buffer, row.embedding.byteOffset, tables.dimensions)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
backfill(missingRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.db
|
||||||
|
.prepare<[], { count: number }>(
|
||||||
|
`SELECT COUNT(*) AS count
|
||||||
|
FROM ${tables.quotedVectorTableName} vec
|
||||||
|
JOIN ${tables.quotedRowidTableName} rowids ON rowids.rowid = vec.rowid`
|
||||||
|
)
|
||||||
|
.get()?.count ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteEmbeddingRefs(rows: StoredEmbeddingRef[]): void {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRows = this.db.transaction((refs: StoredEmbeddingRef[]) => {
|
||||||
|
for (const ref of refs) {
|
||||||
|
this.deleteEmbedding(ref.profile_id, ref.snippet_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
removeRows(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProfileStoreTables(
|
||||||
|
profileId: string,
|
||||||
|
preferredDimensions?: number
|
||||||
|
): ProfileStoreTables {
|
||||||
|
loadSqliteVec(this.db);
|
||||||
|
|
||||||
|
const dimensionsRow = this.db
|
||||||
|
.prepare<
|
||||||
|
[string],
|
||||||
|
ProfileDimensionsRow
|
||||||
|
>('SELECT dimensions FROM embedding_profiles WHERE id = ?')
|
||||||
|
.get(profileId);
|
||||||
|
if (!dimensionsRow) {
|
||||||
|
throw new Error(`Embedding profile not found: ${profileId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedDimensions = this.db
|
||||||
|
.prepare<[string], StoredDimensionsRow>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) AS count,
|
||||||
|
MIN(dimensions) AS min_dimensions,
|
||||||
|
MAX(dimensions) AS max_dimensions
|
||||||
|
FROM snippet_embeddings
|
||||||
|
WHERE profile_id = ?`
|
||||||
|
)
|
||||||
|
.get(profileId);
|
||||||
|
|
||||||
|
const effectiveDimensions = this.resolveDimensions(
|
||||||
|
profileId,
|
||||||
|
dimensionsRow.dimensions,
|
||||||
|
storedDimensions,
|
||||||
|
preferredDimensions
|
||||||
|
);
|
||||||
|
|
||||||
|
const vectorTableName = sqliteVecTableName(profileId);
|
||||||
|
const rowidTableName = sqliteVecRowidTableName(profileId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
vectorTableName,
|
||||||
|
rowidTableName,
|
||||||
|
quotedVectorTableName: quoteSqliteIdentifier(vectorTableName),
|
||||||
|
quotedRowidTableName: quoteSqliteIdentifier(rowidTableName),
|
||||||
|
dimensions: effectiveDimensions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveDimensions(
|
||||||
|
profileId: string,
|
||||||
|
profileDimensions: number,
|
||||||
|
storedDimensions: StoredDimensionsRow | undefined,
|
||||||
|
preferredDimensions?: number
|
||||||
|
): number {
|
||||||
|
if (storedDimensions && storedDimensions.count > 0) {
|
||||||
|
if (storedDimensions.min_dimensions !== storedDimensions.max_dimensions) {
|
||||||
|
throw new Error(`Stored embedding dimensions are inconsistent for profile ${profileId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalDimensions = storedDimensions.min_dimensions;
|
||||||
|
if (canonicalDimensions === null) {
|
||||||
|
throw new Error(`Stored embedding dimensions are missing for profile ${profileId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferredDimensions !== undefined && preferredDimensions !== canonicalDimensions) {
|
||||||
|
throw new Error(
|
||||||
|
`Embedding dimension mismatch for profile ${profileId}: expected ${canonicalDimensions}, received ${preferredDimensions}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonicalDimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferredDimensions ?? profileDimensions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Vector similarity search over stored snippet embeddings.
|
* Vector similarity search over stored snippet embeddings.
|
||||||
*
|
*
|
||||||
* SQLite does not natively support vector operations, so cosine similarity is
|
* Uses sqlite-vec vector_top_k() for ANN search instead of in-memory cosine
|
||||||
* computed in JavaScript after loading candidate embeddings from the
|
* similarity computation over all embeddings.
|
||||||
* snippet_embeddings table.
|
|
||||||
*
|
|
||||||
* Performance note: For repositories with > 50k snippets, pre-filtering by
|
|
||||||
* FTS5 candidates before computing cosine similarity is recommended. For v1,
|
|
||||||
* in-memory computation is acceptable.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
|
import { SqliteVecStore } from './sqlite-vec.store.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -28,12 +24,6 @@ export interface VectorSearchOptions {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raw DB row from snippet_embeddings joined with snippets. */
|
|
||||||
interface RawEmbeddingRow {
|
|
||||||
snippet_id: string;
|
|
||||||
embedding: Buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Math helpers
|
// Math helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -69,46 +59,26 @@ export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class VectorSearch {
|
export class VectorSearch {
|
||||||
constructor(private readonly db: Database.Database) {}
|
private readonly sqliteVecStore: SqliteVecStore;
|
||||||
|
|
||||||
|
constructor(private readonly db: Database.Database) {
|
||||||
|
this.sqliteVecStore = new SqliteVecStore(db);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search stored embeddings by cosine similarity to the query embedding.
|
* Search stored embeddings by cosine similarity to the query embedding.
|
||||||
*
|
*
|
||||||
|
* Uses in-memory cosine similarity computation. The vec_embedding column
|
||||||
|
* stores raw Float32 bytes for forward compatibility with vector-capable
|
||||||
|
* libSQL builds; scoring is performed in JS using the same bytes.
|
||||||
|
*
|
||||||
* @param queryEmbedding - The embedded representation of the search query.
|
* @param queryEmbedding - The embedded representation of the search query.
|
||||||
* @param options - Search options including repositoryId, optional versionId, profileId, and limit.
|
* @param options - Search options including repositoryId, optional versionId, profileId, and limit.
|
||||||
* @returns Results sorted by descending cosine similarity score.
|
* @returns Results sorted by descending cosine similarity score.
|
||||||
*/
|
*/
|
||||||
vectorSearch(queryEmbedding: Float32Array, options: VectorSearchOptions): VectorSearchResult[] {
|
vectorSearch(queryEmbedding: Float32Array, options: VectorSearchOptions): VectorSearchResult[] {
|
||||||
const { repositoryId, versionId, profileId = 'local-default', limit = 50 } = options;
|
return this.sqliteVecStore
|
||||||
|
.queryNearestNeighbors(queryEmbedding, options)
|
||||||
let sql = `
|
.map((result) => ({ snippetId: result.snippetId, score: result.score }));
|
||||||
SELECT se.snippet_id, se.embedding
|
|
||||||
FROM snippet_embeddings se
|
|
||||||
JOIN snippets s ON s.id = se.snippet_id
|
|
||||||
WHERE s.repository_id = ?
|
|
||||||
AND se.profile_id = ?
|
|
||||||
`;
|
|
||||||
const params: unknown[] = [repositoryId, profileId];
|
|
||||||
|
|
||||||
if (versionId) {
|
|
||||||
sql += ' AND s.version_id = ?';
|
|
||||||
params.push(versionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = this.db.prepare<unknown[], RawEmbeddingRow>(sql).all(...params);
|
|
||||||
|
|
||||||
const scored: VectorSearchResult[] = rows.map((row) => {
|
|
||||||
const embedding = new Float32Array(
|
|
||||||
row.embedding.buffer,
|
|
||||||
row.embedding.byteOffset,
|
|
||||||
row.embedding.byteLength / 4
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
snippetId: row.snippet_id,
|
|
||||||
score: cosineSimilarity(queryEmbedding, embedding)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
import type { EmbeddingSettingsUpdateDto } from '$lib/dtos/embedding-settings.js';
|
import type { EmbeddingSettingsUpdateDto } from '$lib/dtos/embedding-settings.js';
|
||||||
import { createProviderFromProfile, getDefaultLocalProfile } from '$lib/server/embeddings/registry.js';
|
import {
|
||||||
|
createProviderFromProfile,
|
||||||
|
getDefaultLocalProfile
|
||||||
|
} from '$lib/server/embeddings/registry.js';
|
||||||
import { EmbeddingProfileMapper } from '$lib/server/mappers/embedding-profile.mapper.js';
|
import { EmbeddingProfileMapper } from '$lib/server/mappers/embedding-profile.mapper.js';
|
||||||
import { EmbeddingProfile, EmbeddingProfileEntity } from '$lib/server/models/embedding-profile.js';
|
import { EmbeddingProfile, EmbeddingProfileEntity } from '$lib/server/models/embedding-profile.js';
|
||||||
import { EmbeddingSettings } from '$lib/server/models/embedding-settings.js';
|
import { EmbeddingSettings } from '$lib/server/models/embedding-settings.js';
|
||||||
@@ -94,7 +97,10 @@ export class EmbeddingSettingsService {
|
|||||||
private getCreatedAt(id: string, fallback: number): number {
|
private getCreatedAt(id: string, fallback: number): number {
|
||||||
return (
|
return (
|
||||||
this.db
|
this.db
|
||||||
.prepare<[string], { created_at: number }>('SELECT created_at FROM embedding_profiles WHERE id = ?')
|
.prepare<
|
||||||
|
[string],
|
||||||
|
{ created_at: number }
|
||||||
|
>('SELECT created_at FROM embedding_profiles WHERE id = ?')
|
||||||
.get(id)?.created_at ?? fallback
|
.get(id)?.created_at ?? fallback
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import Database from 'better-sqlite3';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { RepositoryService } from './repository.service';
|
import { RepositoryService } from './repository.service';
|
||||||
|
import {
|
||||||
|
loadSqliteVec,
|
||||||
|
sqliteVecRowidTableName,
|
||||||
|
sqliteVecTableName
|
||||||
|
} from '$lib/server/db/sqlite-vec.js';
|
||||||
|
import { SqliteVecStore } from '$lib/server/search/sqlite-vec.store.js';
|
||||||
import {
|
import {
|
||||||
AlreadyExistsError,
|
AlreadyExistsError,
|
||||||
InvalidInputError,
|
InvalidInputError,
|
||||||
@@ -25,6 +31,7 @@ import {
|
|||||||
function createTestDb(): Database.Database {
|
function createTestDb(): Database.Database {
|
||||||
const client = new Database(':memory:');
|
const client = new Database(':memory:');
|
||||||
client.pragma('foreign_keys = ON');
|
client.pragma('foreign_keys = ON');
|
||||||
|
loadSqliteVec(client);
|
||||||
|
|
||||||
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
||||||
|
|
||||||
@@ -33,7 +40,9 @@ function createTestDb(): Database.Database {
|
|||||||
'0001_quick_nighthawk.sql',
|
'0001_quick_nighthawk.sql',
|
||||||
'0002_silky_stellaris.sql',
|
'0002_silky_stellaris.sql',
|
||||||
'0003_multiversion_config.sql',
|
'0003_multiversion_config.sql',
|
||||||
'0004_complete_sentry.sql'
|
'0004_complete_sentry.sql',
|
||||||
|
'0005_fix_stage_defaults.sql',
|
||||||
|
'0006_yielding_centennial.sql'
|
||||||
]) {
|
]) {
|
||||||
const statements = readFileSync(join(migrationsFolder, migration), 'utf-8')
|
const statements = readFileSync(join(migrationsFolder, migration), 'utf-8')
|
||||||
.split('--> statement-breakpoint')
|
.split('--> statement-breakpoint')
|
||||||
@@ -331,6 +340,41 @@ describe('RepositoryService.remove()', () => {
|
|||||||
it('throws NotFoundError when the repository does not exist', () => {
|
it('throws NotFoundError when the repository does not exist', () => {
|
||||||
expect(() => service.remove('/not/found')).toThrow(NotFoundError);
|
expect(() => service.remove('/not/found')).toThrow(NotFoundError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes derived vec rows before the repository cascade deletes snippets', () => {
|
||||||
|
const docId = crypto.randomUUID();
|
||||||
|
const snippetId = crypto.randomUUID();
|
||||||
|
const embedding = Float32Array.from([1, 0, 0]);
|
||||||
|
const vecStore = new SqliteVecStore((service as unknown as { db: Database.Database }).db);
|
||||||
|
const db = (service as unknown as { db: Database.Database }).db;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO documents (id, repository_id, version_id, file_path, checksum, indexed_at)
|
||||||
|
VALUES (?, '/facebook/react', NULL, 'README.md', 'repo-doc', ?)`
|
||||||
|
).run(docId, now);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO snippets (id, document_id, repository_id, version_id, type, content, created_at)
|
||||||
|
VALUES (?, ?, '/facebook/react', NULL, 'info', 'repo snippet', ?)`
|
||||||
|
).run(snippetId, docId, now);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
|
||||||
|
VALUES (?, 'local-default', 'test-model', 3, ?, ?)`
|
||||||
|
).run(snippetId, Buffer.from(embedding.buffer), now);
|
||||||
|
vecStore.upsertEmbedding('local-default', snippetId, embedding);
|
||||||
|
|
||||||
|
service.remove('/facebook/react');
|
||||||
|
|
||||||
|
const vecTable = sqliteVecTableName('local-default');
|
||||||
|
const rowidTable = sqliteVecRowidTableName('local-default');
|
||||||
|
const vecCount = db.prepare(`SELECT COUNT(*) as n FROM "${vecTable}"`).get() as { n: number };
|
||||||
|
const rowidCount = db.prepare(`SELECT COUNT(*) as n FROM "${rowidTable}"`).get() as {
|
||||||
|
n: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(vecCount.n).toBe(0);
|
||||||
|
expect(rowidCount.n).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -425,7 +469,11 @@ describe('RepositoryService.getIndexSummary()', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
client = createTestDb();
|
client = createTestDb();
|
||||||
service = makeService(client);
|
service = makeService(client);
|
||||||
service.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react', branch: 'main' });
|
service.add({
|
||||||
|
source: 'github',
|
||||||
|
sourceUrl: 'https://github.com/facebook/react',
|
||||||
|
branch: 'main'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns embedding counts and indexed version labels', () => {
|
it('returns embedding counts and indexed version labels', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { RepositoryMapper } from '$lib/server/mappers/repository.mapper.js';
|
|||||||
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
|
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
|
||||||
import { Repository, RepositoryEntity } from '$lib/server/models/repository.js';
|
import { Repository, RepositoryEntity } from '$lib/server/models/repository.js';
|
||||||
import { IndexingJob, IndexingJobEntity } from '$lib/server/models/indexing-job.js';
|
import { IndexingJob, IndexingJobEntity } from '$lib/server/models/indexing-job.js';
|
||||||
|
import { SqliteVecStore } from '$lib/server/search/sqlite-vec.store.js';
|
||||||
import { resolveGitHubId, resolveLocalId } from '$lib/server/utils/id-resolver';
|
import { resolveGitHubId, resolveLocalId } from '$lib/server/utils/id-resolver';
|
||||||
import {
|
import {
|
||||||
AlreadyExistsError,
|
AlreadyExistsError,
|
||||||
@@ -230,7 +231,11 @@ export class RepositoryService {
|
|||||||
const existing = this.get(id);
|
const existing = this.get(id);
|
||||||
if (!existing) throw new NotFoundError(`Repository ${id} not found`);
|
if (!existing) throw new NotFoundError(`Repository ${id} not found`);
|
||||||
|
|
||||||
this.db.prepare(`DELETE FROM repositories WHERE id = ?`).run(id);
|
const sqliteVecStore = new SqliteVecStore(this.db);
|
||||||
|
this.db.transaction(() => {
|
||||||
|
sqliteVecStore.deleteEmbeddingsForRepository(id);
|
||||||
|
this.db.prepare(`DELETE FROM repositories WHERE id = ?`).run(id);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
loadSqliteVec,
|
||||||
|
sqliteVecRowidTableName,
|
||||||
|
sqliteVecTableName
|
||||||
|
} from '$lib/server/db/sqlite-vec.js';
|
||||||
|
import { SqliteVecStore } from '$lib/server/search/sqlite-vec.store.js';
|
||||||
import { VersionService } from './version.service';
|
import { VersionService } from './version.service';
|
||||||
import { RepositoryService } from './repository.service';
|
import { RepositoryService } from './repository.service';
|
||||||
import { AlreadyExistsError, NotFoundError } from '$lib/server/utils/validation';
|
import { AlreadyExistsError, NotFoundError } from '$lib/server/utils/validation';
|
||||||
@@ -21,31 +27,27 @@ import { AlreadyExistsError, NotFoundError } from '$lib/server/utils/validation'
|
|||||||
function createTestDb(): Database.Database {
|
function createTestDb(): Database.Database {
|
||||||
const client = new Database(':memory:');
|
const client = new Database(':memory:');
|
||||||
client.pragma('foreign_keys = ON');
|
client.pragma('foreign_keys = ON');
|
||||||
|
loadSqliteVec(client);
|
||||||
|
|
||||||
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
const migrationsFolder = join(import.meta.dirname, '../db/migrations');
|
||||||
|
|
||||||
// Apply all migration files in order
|
for (const migration of [
|
||||||
const migration0 = readFileSync(join(migrationsFolder, '0000_large_master_chief.sql'), 'utf-8');
|
'0000_large_master_chief.sql',
|
||||||
const migration1 = readFileSync(join(migrationsFolder, '0001_quick_nighthawk.sql'), 'utf-8');
|
'0001_quick_nighthawk.sql',
|
||||||
|
'0002_silky_stellaris.sql',
|
||||||
|
'0003_multiversion_config.sql',
|
||||||
|
'0004_complete_sentry.sql',
|
||||||
|
'0005_fix_stage_defaults.sql',
|
||||||
|
'0006_yielding_centennial.sql'
|
||||||
|
]) {
|
||||||
|
const statements = readFileSync(join(migrationsFolder, migration), 'utf-8')
|
||||||
|
.split('--> statement-breakpoint')
|
||||||
|
.map((statement) => statement.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
// Apply first migration
|
for (const statement of statements) {
|
||||||
const statements0 = migration0
|
client.exec(statement);
|
||||||
.split('--> statement-breakpoint')
|
}
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
for (const stmt of statements0) {
|
|
||||||
client.exec(stmt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply second migration
|
|
||||||
const statements1 = migration1
|
|
||||||
.split('--> statement-breakpoint')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
for (const stmt of statements1) {
|
|
||||||
client.exec(stmt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
@@ -198,6 +200,50 @@ describe('VersionService.remove()', () => {
|
|||||||
const doc = client.prepare(`SELECT id FROM documents WHERE id = ?`).get(docId);
|
const doc = client.prepare(`SELECT id FROM documents WHERE id = ?`).get(docId);
|
||||||
expect(doc).toBeUndefined();
|
expect(doc).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes derived vec rows before deleting the version', () => {
|
||||||
|
const { client, versionService } = setup();
|
||||||
|
const version = versionService.add('/facebook/react', 'v18.3.0');
|
||||||
|
const docId = crypto.randomUUID();
|
||||||
|
const snippetId = crypto.randomUUID();
|
||||||
|
const embedding = Float32Array.from([0.5, 0.25, 0.125]);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const vecStore = new SqliteVecStore(client);
|
||||||
|
|
||||||
|
client
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO documents (id, repository_id, version_id, file_path, checksum, indexed_at)
|
||||||
|
VALUES (?, '/facebook/react', ?, 'README.md', 'version-doc', ?)`
|
||||||
|
)
|
||||||
|
.run(docId, version.id, now);
|
||||||
|
client
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO snippets (id, document_id, repository_id, version_id, type, content, created_at)
|
||||||
|
VALUES (?, ?, '/facebook/react', ?, 'info', 'version snippet', ?)`
|
||||||
|
)
|
||||||
|
.run(snippetId, docId, version.id, now);
|
||||||
|
client
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO snippet_embeddings (snippet_id, profile_id, model, dimensions, embedding, created_at)
|
||||||
|
VALUES (?, 'local-default', 'test-model', 3, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(snippetId, Buffer.from(embedding.buffer), now);
|
||||||
|
vecStore.upsertEmbedding('local-default', snippetId, embedding);
|
||||||
|
|
||||||
|
versionService.remove('/facebook/react', 'v18.3.0');
|
||||||
|
|
||||||
|
const vecTable = sqliteVecTableName('local-default');
|
||||||
|
const rowidTable = sqliteVecRowidTableName('local-default');
|
||||||
|
const vecCount = client.prepare(`SELECT COUNT(*) as n FROM "${vecTable}"`).get() as {
|
||||||
|
n: number;
|
||||||
|
};
|
||||||
|
const rowidCount = client.prepare(`SELECT COUNT(*) as n FROM "${rowidTable}"`).get() as {
|
||||||
|
n: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(vecCount.n).toBe(0);
|
||||||
|
expect(rowidCount.n).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
RepositoryVersion,
|
RepositoryVersion,
|
||||||
RepositoryVersionEntity
|
RepositoryVersionEntity
|
||||||
} from '$lib/server/models/repository-version.js';
|
} from '$lib/server/models/repository-version.js';
|
||||||
|
import { SqliteVecStore } from '$lib/server/search/sqlite-vec.store.js';
|
||||||
import { AlreadyExistsError, NotFoundError } from '$lib/server/utils/validation';
|
import { AlreadyExistsError, NotFoundError } from '$lib/server/utils/validation';
|
||||||
import { resolveTagToCommit, discoverVersionTags } from '$lib/server/utils/git.js';
|
import { resolveTagToCommit, discoverVersionTags } from '$lib/server/utils/git.js';
|
||||||
|
|
||||||
@@ -99,9 +100,13 @@ export class VersionService {
|
|||||||
throw new NotFoundError(`Version ${tag} not found for repository ${repositoryId}`);
|
throw new NotFoundError(`Version ${tag} not found for repository ${repositoryId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.db
|
const sqliteVecStore = new SqliteVecStore(this.db);
|
||||||
.prepare(`DELETE FROM repository_versions WHERE repository_id = ? AND tag = ?`)
|
this.db.transaction(() => {
|
||||||
.run(repositoryId, tag);
|
sqliteVecStore.deleteEmbeddingsForVersion(repositoryId, version.id);
|
||||||
|
this.db
|
||||||
|
.prepare(`DELETE FROM repository_versions WHERE repository_id = ? AND tag = ?`)
|
||||||
|
.run(repositoryId, tag);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { RepositoryVersion } from '$lib/server/models/repository-version.js';
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function makeVersion(tag: string, state: RepositoryVersion['state'] = 'indexed'): RepositoryVersion {
|
function makeVersion(
|
||||||
|
tag: string,
|
||||||
|
state: RepositoryVersion['state'] = 'indexed'
|
||||||
|
): RepositoryVersion {
|
||||||
return new RepositoryVersion({
|
return new RepositoryVersion({
|
||||||
id: `/facebook/react/${tag}`,
|
id: `/facebook/react/${tag}`,
|
||||||
repositoryId: '/facebook/react',
|
repositoryId: '/facebook/react',
|
||||||
@@ -42,21 +45,13 @@ describe('findBestAncestorVersion', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns the nearest semver predecessor from a list', () => {
|
it('returns the nearest semver predecessor from a list', () => {
|
||||||
const candidates = [
|
const candidates = [makeVersion('v1.0.0'), makeVersion('v1.1.0'), makeVersion('v2.0.0')];
|
||||||
makeVersion('v1.0.0'),
|
|
||||||
makeVersion('v1.1.0'),
|
|
||||||
makeVersion('v2.0.0')
|
|
||||||
];
|
|
||||||
const result = findBestAncestorVersion('v2.1.0', candidates);
|
const result = findBestAncestorVersion('v2.1.0', candidates);
|
||||||
expect(result?.tag).toBe('v2.0.0');
|
expect(result?.tag).toBe('v2.0.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles v-prefix stripping correctly', () => {
|
it('handles v-prefix stripping correctly', () => {
|
||||||
const candidates = [
|
const candidates = [makeVersion('v1.0.0'), makeVersion('v1.5.0'), makeVersion('v2.0.0')];
|
||||||
makeVersion('v1.0.0'),
|
|
||||||
makeVersion('v1.5.0'),
|
|
||||||
makeVersion('v2.0.0')
|
|
||||||
];
|
|
||||||
const result = findBestAncestorVersion('v2.0.1', candidates);
|
const result = findBestAncestorVersion('v2.0.1', candidates);
|
||||||
expect(result?.tag).toBe('v2.0.0');
|
expect(result?.tag).toBe('v2.0.0');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,16 @@ export type RepositorySource = 'github' | 'local';
|
|||||||
export type RepositoryState = 'pending' | 'indexing' | 'indexed' | 'error';
|
export type RepositoryState = 'pending' | 'indexing' | 'indexed' | 'error';
|
||||||
export type SnippetType = 'code' | 'info';
|
export type SnippetType = 'code' | 'info';
|
||||||
export type JobStatus = 'queued' | 'running' | 'done' | 'failed';
|
export type JobStatus = 'queued' | 'running' | 'done' | 'failed';
|
||||||
export type IndexingStage = 'queued' | 'differential' | 'crawling' | 'cloning' | 'parsing' | 'storing' | 'embedding' | 'done' | 'failed';
|
export type IndexingStage =
|
||||||
|
| 'queued'
|
||||||
|
| 'differential'
|
||||||
|
| 'crawling'
|
||||||
|
| 'cloning'
|
||||||
|
| 'parsing'
|
||||||
|
| 'storing'
|
||||||
|
| 'embedding'
|
||||||
|
| 'done'
|
||||||
|
| 'failed';
|
||||||
export type VersionState = 'pending' | 'indexing' | 'indexed' | 'error';
|
export type VersionState = 'pending' | 'indexing' | 'indexed' | 'error';
|
||||||
export type EmbeddingProviderKind = 'local-transformers' | 'openai-compatible';
|
export type EmbeddingProviderKind = 'local-transformers' | 'openai-compatible';
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@
|
|||||||
<a href={resolveRoute('/search')} class="text-sm text-gray-600 hover:text-gray-900">
|
<a href={resolveRoute('/search')} class="text-sm text-gray-600 hover:text-gray-900">
|
||||||
Search
|
Search
|
||||||
</a>
|
</a>
|
||||||
|
<a href={resolveRoute('/admin/jobs')} class="text-sm text-gray-600 hover:text-gray-900">
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
<a href={resolveRoute('/settings')} class="text-sm text-gray-600 hover:text-gray-900">
|
<a href={resolveRoute('/settings')} class="text-sm text-gray-600 hover:text-gray-900">
|
||||||
Settings
|
Settings
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
|
import JobSkeleton from '$lib/components/admin/JobSkeleton.svelte';
|
||||||
import JobStatusBadge from '$lib/components/admin/JobStatusBadge.svelte';
|
import JobStatusBadge from '$lib/components/admin/JobStatusBadge.svelte';
|
||||||
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
|
import WorkerStatusPanel from '$lib/components/admin/WorkerStatusPanel.svelte';
|
||||||
import type { IndexingJobDto } from '$lib/server/models/indexing-job.js';
|
import type { IndexingJobDto } from '$lib/server/models/indexing-job.js';
|
||||||
|
|
||||||
interface JobResponse {
|
interface JobResponse {
|
||||||
@@ -7,174 +12,16 @@
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let jobs = $state<IndexingJobDto[]>([]);
|
interface ToastItem {
|
||||||
let loading = $state(true);
|
id: string;
|
||||||
let error = $state<string | null>(null);
|
message: string;
|
||||||
let actionInProgress = $state<string | null>(null);
|
type: 'success' | 'error' | 'info';
|
||||||
|
|
||||||
// Fetch jobs from API
|
|
||||||
async function fetchJobs() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/jobs?limit=50');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
const data: JobResponse = await response.json();
|
|
||||||
jobs = data.jobs;
|
|
||||||
error = null;
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : 'Failed to fetch jobs';
|
|
||||||
console.error('Failed to fetch jobs:', err);
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action handlers
|
type FilterStatus = 'queued' | 'running' | 'done' | 'failed';
|
||||||
async function pauseJob(id: string) {
|
type JobAction = 'pause' | 'resume' | 'cancel';
|
||||||
actionInProgress = id;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/jobs/${id}/pause`, { method: 'POST' });
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
|
|
||||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
// Optimistic update
|
|
||||||
jobs = jobs.map((j) => (j.id === id ? { ...j, status: 'paused' as const } : j));
|
|
||||||
// Show success message
|
|
||||||
showToast('Job paused successfully');
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to pause job';
|
|
||||||
showToast(msg, 'error');
|
|
||||||
console.error('Failed to pause job:', err);
|
|
||||||
} finally {
|
|
||||||
actionInProgress = null;
|
|
||||||
// Refresh after a short delay to get the actual state
|
|
||||||
setTimeout(fetchJobs, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeJob(id: string) {
|
const filterStatuses: FilterStatus[] = ['queued', 'running', 'done', 'failed'];
|
||||||
actionInProgress = id;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/jobs/${id}/resume`, { method: 'POST' });
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
|
|
||||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
// Optimistic update
|
|
||||||
jobs = jobs.map((j) => (j.id === id ? { ...j, status: 'queued' as const } : j));
|
|
||||||
showToast('Job resumed successfully');
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to resume job';
|
|
||||||
showToast(msg, 'error');
|
|
||||||
console.error('Failed to resume job:', err);
|
|
||||||
} finally {
|
|
||||||
actionInProgress = null;
|
|
||||||
setTimeout(fetchJobs, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancelJob(id: string) {
|
|
||||||
if (!confirm('Are you sure you want to cancel this job?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
actionInProgress = id;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/jobs/${id}/cancel`, { method: 'POST' });
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
|
|
||||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
// Optimistic update
|
|
||||||
jobs = jobs.map((j) => (j.id === id ? { ...j, status: 'cancelled' as const } : j));
|
|
||||||
showToast('Job cancelled successfully');
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to cancel job';
|
|
||||||
showToast(msg, 'error');
|
|
||||||
console.error('Failed to cancel job:', err);
|
|
||||||
} finally {
|
|
||||||
actionInProgress = null;
|
|
||||||
setTimeout(fetchJobs, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple toast notification (using alert for v1, can be enhanced later)
|
|
||||||
function showToast(message: string, type: 'success' | 'error' = 'success') {
|
|
||||||
// For v1, just use alert. In production, integrate with a toast library.
|
|
||||||
if (type === 'error') {
|
|
||||||
alert(`Error: ${message}`);
|
|
||||||
} else {
|
|
||||||
console.log(`✓ ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-refresh with EventSource streaming + fallback polling
|
|
||||||
$effect(() => {
|
|
||||||
fetchJobs();
|
|
||||||
|
|
||||||
const es = new EventSource('/api/v1/jobs/stream');
|
|
||||||
let fallbackInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
es.addEventListener('job-progress', (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
jobs = jobs.map((j) =>
|
|
||||||
j.id === data.jobId
|
|
||||||
? {
|
|
||||||
...j,
|
|
||||||
progress: data.progress,
|
|
||||||
stage: data.stage,
|
|
||||||
stageDetail: data.stageDetail,
|
|
||||||
processedFiles: data.processedFiles,
|
|
||||||
totalFiles: data.totalFiles
|
|
||||||
}
|
|
||||||
: j
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
es.addEventListener('job-done', () => {
|
|
||||||
void fetchJobs();
|
|
||||||
});
|
|
||||||
|
|
||||||
es.addEventListener('job-failed', () => {
|
|
||||||
void fetchJobs();
|
|
||||||
});
|
|
||||||
|
|
||||||
es.onerror = () => {
|
|
||||||
es.close();
|
|
||||||
// Fall back to polling on error
|
|
||||||
fallbackInterval = setInterval(fetchJobs, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
es.close();
|
|
||||||
if (fallbackInterval) {
|
|
||||||
clearInterval(fallbackInterval);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Format date for display
|
|
||||||
function formatDate(date: Date | null): string {
|
|
||||||
if (!date) return '—';
|
|
||||||
return new Date(date).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which actions are available for a job
|
|
||||||
function canPause(status: IndexingJobDto['status']): boolean {
|
|
||||||
return status === 'queued' || status === 'running';
|
|
||||||
}
|
|
||||||
|
|
||||||
function canResume(status: IndexingJobDto['status']): boolean {
|
|
||||||
return status === 'paused';
|
|
||||||
}
|
|
||||||
|
|
||||||
function canCancel(status: IndexingJobDto['status']): boolean {
|
|
||||||
return status !== 'done' && status !== 'failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map IndexingStage values to display labels
|
|
||||||
const stageLabels: Record<string, string> = {
|
const stageLabels: Record<string, string> = {
|
||||||
queued: 'Queued',
|
queued: 'Queued',
|
||||||
differential: 'Diff',
|
differential: 'Diff',
|
||||||
@@ -187,9 +34,278 @@
|
|||||||
failed: 'Failed'
|
failed: 'Failed'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let jobs = $state<IndexingJobDto[]>([]);
|
||||||
|
let total = $state(0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let repositoryInput = $state('');
|
||||||
|
let selectedStatuses = $state<FilterStatus[]>([]);
|
||||||
|
let appliedRepositoryFilter = $state('');
|
||||||
|
let appliedStatuses = $state<FilterStatus[]>([]);
|
||||||
|
let pendingCancelJobId = $state<string | null>(null);
|
||||||
|
let rowActions = $state<Record<string, JobAction | undefined>>({});
|
||||||
|
let toasts = $state<ToastItem[]>([]);
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function buildJobsUrl(): string {
|
||||||
|
const params = new SvelteURLSearchParams({ limit: '50' });
|
||||||
|
|
||||||
|
if (appliedRepositoryFilter) {
|
||||||
|
params.set('repositoryId', appliedRepositoryFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appliedStatuses.length > 0) {
|
||||||
|
params.set('status', appliedStatuses.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/api/v1/jobs?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushToast(message: string, type: ToastItem['type'] = 'success') {
|
||||||
|
toasts = [...toasts, { id: crypto.randomUUID(), message, type }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRowAction(jobId: string) {
|
||||||
|
const next = { ...rowActions };
|
||||||
|
delete next[jobId];
|
||||||
|
rowActions = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRowAction(jobId: string, action: JobAction) {
|
||||||
|
rowActions = { ...rowActions, [jobId]: action };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRefresh(delayMs = 500) {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTimer = setTimeout(() => {
|
||||||
|
void fetchJobs({ background: true });
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAppliedFilters(): boolean {
|
||||||
|
return appliedRepositoryFilter.length > 0 || appliedStatuses.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameStatuses(left: FilterStatus[], right: FilterStatus[]): boolean {
|
||||||
|
return left.length === right.length && left.every((status, index) => status === right[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtersDirty(): boolean {
|
||||||
|
return (
|
||||||
|
repositoryInput.trim() !== appliedRepositoryFilter ||
|
||||||
|
!sameStatuses(selectedStatuses, appliedStatuses)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSpecificRepositoryId(repositoryId: string): boolean {
|
||||||
|
return repositoryId.split('/').filter(Boolean).length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAppliedFilters(job: IndexingJobDto): boolean {
|
||||||
|
if (appliedRepositoryFilter) {
|
||||||
|
const repositoryFilter = appliedRepositoryFilter;
|
||||||
|
const repositoryMatches = isSpecificRepositoryId(repositoryFilter)
|
||||||
|
? job.repositoryId === repositoryFilter
|
||||||
|
: job.repositoryId === repositoryFilter ||
|
||||||
|
job.repositoryId.startsWith(`${repositoryFilter}/`);
|
||||||
|
|
||||||
|
if (!repositoryMatches) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appliedStatuses.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appliedStatuses.includes(job.status as FilterStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCancelState(nextJobs: IndexingJobDto[]) {
|
||||||
|
if (!pendingCancelJobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingJob = nextJobs.find((job) => job.id === pendingCancelJobId);
|
||||||
|
if (!pendingJob || !canCancel(pendingJob.status)) {
|
||||||
|
pendingCancelJobId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJobs(options: { background?: boolean } = {}) {
|
||||||
|
const background = options.background ?? false;
|
||||||
|
|
||||||
|
if (background) {
|
||||||
|
refreshing = true;
|
||||||
|
} else {
|
||||||
|
loading = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildJobsUrl());
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: JobResponse = await response.json();
|
||||||
|
jobs = data.jobs;
|
||||||
|
total = data.total;
|
||||||
|
error = null;
|
||||||
|
syncCancelState(data.jobs);
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Failed to fetch jobs';
|
||||||
|
console.error('Failed to fetch jobs:', err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runJobAction(job: IndexingJobDto, action: JobAction) {
|
||||||
|
setRowAction(job.id, action);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/jobs/${job.id}/${action}`, { method: 'POST' });
|
||||||
|
const payload = await response.json().catch(() => ({ message: 'Unknown error' }));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedJob = payload.job as IndexingJobDto | undefined;
|
||||||
|
if (updatedJob) {
|
||||||
|
if (matchesAppliedFilters(updatedJob)) {
|
||||||
|
jobs = jobs.map((currentJob) =>
|
||||||
|
currentJob.id === updatedJob.id ? updatedJob : currentJob
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jobs = jobs.filter((currentJob) => currentJob.id !== updatedJob.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingCancelJobId = null;
|
||||||
|
pushToast(`Job ${action}d successfully`);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : `Failed to ${action} job`;
|
||||||
|
pushToast(message, 'error');
|
||||||
|
console.error(`Failed to ${action} job:`, err);
|
||||||
|
} finally {
|
||||||
|
clearRowAction(job.id);
|
||||||
|
scheduleRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStatusFilter(status: FilterStatus) {
|
||||||
|
selectedStatuses = selectedStatuses.includes(status)
|
||||||
|
? selectedStatuses.filter((candidate) => candidate !== status)
|
||||||
|
: [...selectedStatuses, status].sort(
|
||||||
|
(left, right) => filterStatuses.indexOf(left) - filterStatuses.indexOf(right)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters(event?: SubmitEvent) {
|
||||||
|
event?.preventDefault();
|
||||||
|
appliedRepositoryFilter = repositoryInput.trim();
|
||||||
|
appliedStatuses = [...selectedStatuses];
|
||||||
|
pendingCancelJobId = null;
|
||||||
|
void fetchJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
repositoryInput = '';
|
||||||
|
selectedStatuses = [];
|
||||||
|
appliedRepositoryFilter = '';
|
||||||
|
appliedStatuses = [];
|
||||||
|
pendingCancelJobId = null;
|
||||||
|
void fetchJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCancel(jobId: string) {
|
||||||
|
pendingCancelJobId = pendingCancelJobId === jobId ? null : jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date | string | null): string {
|
||||||
|
if (!date) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(date).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPause(status: IndexingJobDto['status']): boolean {
|
||||||
|
return status === 'queued' || status === 'running';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canResume(status: IndexingJobDto['status']): boolean {
|
||||||
|
return status === 'paused';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCancel(status: IndexingJobDto['status']): boolean {
|
||||||
|
return status !== 'done' && status !== 'failed' && status !== 'cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRowBusy(jobId: string): boolean {
|
||||||
|
return Boolean(rowActions[jobId]);
|
||||||
|
}
|
||||||
|
|
||||||
function getStageLabel(stage: string | undefined): string {
|
function getStageLabel(stage: string | undefined): string {
|
||||||
return stage ? (stageLabels[stage] ?? stage) : '—';
|
return stage ? (stageLabels[stage] ?? stage) : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void fetchJobs();
|
||||||
|
|
||||||
|
const es = new EventSource('/api/v1/jobs/stream');
|
||||||
|
let fallbackInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const refreshJobs = () => {
|
||||||
|
void fetchJobs({ background: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
es.addEventListener('job-progress', (event) => {
|
||||||
|
const data = JSON.parse(event.data) as Partial<IndexingJobDto> & { jobId?: string };
|
||||||
|
if (!data.jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs = jobs.map((job) =>
|
||||||
|
job.id === data.jobId
|
||||||
|
? {
|
||||||
|
...job,
|
||||||
|
progress: data.progress ?? job.progress,
|
||||||
|
stage: data.stage ?? job.stage,
|
||||||
|
stageDetail: data.stageDetail ?? job.stageDetail,
|
||||||
|
processedFiles: data.processedFiles ?? job.processedFiles,
|
||||||
|
totalFiles: data.totalFiles ?? job.totalFiles,
|
||||||
|
status: data.status ?? job.status
|
||||||
|
}
|
||||||
|
: job
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('job-done', refreshJobs);
|
||||||
|
es.addEventListener('job-failed', refreshJobs);
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
es.close();
|
||||||
|
if (!fallbackInterval) {
|
||||||
|
fallbackInterval = setInterval(refreshJobs, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es.close();
|
||||||
|
if (fallbackInterval) {
|
||||||
|
clearInterval(fallbackInterval);
|
||||||
|
}
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -202,23 +318,100 @@
|
|||||||
<p class="mt-2 text-gray-600">Monitor and control indexing jobs</p>
|
<p class="mt-2 text-gray-600">Monitor and control indexing jobs</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading && jobs.length === 0}
|
<WorkerStatusPanel />
|
||||||
<div class="flex items-center justify-center py-12">
|
|
||||||
<div class="text-center">
|
<form
|
||||||
<div
|
class="mb-6 rounded-lg border border-gray-200 bg-white p-4 shadow-sm"
|
||||||
class="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
|
onsubmit={applyFilters}
|
||||||
></div>
|
>
|
||||||
<p class="mt-2 text-gray-600">Loading jobs...</p>
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700" for="repository-filter">
|
||||||
|
Repository filter
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="repository-filter"
|
||||||
|
type="text"
|
||||||
|
bind:value={repositoryInput}
|
||||||
|
placeholder="/owner or /owner/repo"
|
||||||
|
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
Use an owner prefix like <code>/facebook</code> or a full repository ID like
|
||||||
|
<code>/facebook/react</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:min-w-72">
|
||||||
|
<span class="mb-2 block text-sm font-medium text-gray-700">Statuses</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each filterStatuses as status (status)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleStatusFilter(status)}
|
||||||
|
class="rounded-full border px-3 py-1 text-xs font-semibold uppercase transition {selectedStatuses.includes(
|
||||||
|
status
|
||||||
|
)
|
||||||
|
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||||
|
: 'border-gray-300 text-gray-600 hover:border-gray-400 hover:text-gray-900'}"
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!filtersDirty()}
|
||||||
|
class="rounded bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Apply filters
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={resetFilters}
|
||||||
|
class="rounded border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 hover:border-gray-400 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if error && jobs.length === 0}
|
</form>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
|
||||||
<p class="text-sm text-red-800">Error: {error}</p>
|
<div
|
||||||
|
class="mb-4 flex flex-col gap-2 text-sm text-gray-600 md:flex-row md:items-center md:justify-between"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Showing <span class="font-semibold text-gray-900">{jobs.length}</span> of
|
||||||
|
<span class="font-semibold text-gray-900">{total}</span> jobs
|
||||||
|
</p>
|
||||||
|
{#if hasAppliedFilters()}
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Active filters:
|
||||||
|
{appliedRepositoryFilter || 'all repositories'}
|
||||||
|
{#if appliedStatuses.length > 0}
|
||||||
|
· {appliedStatuses.join(', ')}
|
||||||
|
{:else}
|
||||||
|
· all statuses
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
</div>
|
</div>
|
||||||
{:else if jobs.length === 0}
|
{/if}
|
||||||
|
|
||||||
|
{#if !loading && jobs.length === 0}
|
||||||
<div class="rounded-md bg-gray-50 p-8 text-center">
|
<div class="rounded-md bg-gray-50 p-8 text-center">
|
||||||
<p class="text-gray-600">
|
<p class="text-gray-600">
|
||||||
No jobs found. Jobs will appear here when repositories are indexed.
|
{hasAppliedFilters()
|
||||||
|
? 'No jobs match the current filters.'
|
||||||
|
: 'No jobs found. Jobs will appear here when repositories are indexed.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -259,86 +452,119 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
{#each jobs as job (job.id)}
|
{#if loading && jobs.length === 0}
|
||||||
<tr class="hover:bg-gray-50">
|
<JobSkeleton rows={6} />
|
||||||
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900">
|
{:else}
|
||||||
{job.repositoryId}
|
{#each jobs as job (job.id)}
|
||||||
{#if job.versionId}
|
<tr class="hover:bg-gray-50">
|
||||||
<span class="ml-1 text-xs text-gray-500">@{job.versionId}</span>
|
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900">
|
||||||
{/if}
|
{job.repositoryId}
|
||||||
</td>
|
{#if job.versionId}
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
<span class="ml-1 text-xs text-gray-500">@{job.versionId}</span>
|
||||||
<JobStatusBadge status={job.status} />
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{getStageLabel(job.stage)}</span>
|
|
||||||
{#if job.stageDetail}
|
|
||||||
<span class="text-xs text-gray-400">{job.stageDetail}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="mr-2">{job.progress}%</span>
|
|
||||||
<div class="h-2 w-32 rounded-full bg-gray-200">
|
|
||||||
<div
|
|
||||||
class="h-2 rounded-full bg-blue-600 transition-all"
|
|
||||||
style="width: {job.progress}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
{#if job.totalFiles > 0}
|
|
||||||
<span class="ml-2 text-xs text-gray-400">
|
|
||||||
{job.processedFiles}/{job.totalFiles} files
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
|
||||||
{formatDate(job.createdAt)}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
{#if canPause(job.status)}
|
|
||||||
<button
|
|
||||||
onclick={() => pauseJob(job.id)}
|
|
||||||
disabled={actionInProgress === job.id}
|
|
||||||
class="rounded bg-yellow-600 px-3 py-1 text-xs font-semibold text-white hover:bg-yellow-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Pause
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if canResume(job.status)}
|
<div class="mt-1 text-xs text-gray-400">{job.id}</div>
|
||||||
<button
|
</td>
|
||||||
onclick={() => resumeJob(job.id)}
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||||
disabled={actionInProgress === job.id}
|
<JobStatusBadge status={job.status} spinning={job.status === 'running'} />
|
||||||
class="rounded bg-green-600 px-3 py-1 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50"
|
</td>
|
||||||
>
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||||
Resume
|
<div class="flex items-center gap-2">
|
||||||
</button>
|
<span>{getStageLabel(job.stage)}</span>
|
||||||
{/if}
|
{#if job.stageDetail}
|
||||||
{#if canCancel(job.status)}
|
<span class="text-xs text-gray-400">{job.stageDetail}</span>
|
||||||
<button
|
{/if}
|
||||||
onclick={() => cancelJob(job.id)}
|
</div>
|
||||||
disabled={actionInProgress === job.id}
|
</td>
|
||||||
class="rounded bg-red-600 px-3 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||||
>
|
<div class="space-y-2">
|
||||||
Cancel
|
<div class="flex items-center gap-2">
|
||||||
</button>
|
<span class="w-12 text-right text-xs font-semibold text-gray-600"
|
||||||
{/if}
|
>{job.progress}%</span
|
||||||
{#if !canPause(job.status) && !canResume(job.status) && !canCancel(job.status)}
|
>
|
||||||
<span class="text-xs text-gray-400">—</span>
|
<div class="h-2 w-32 rounded-full bg-gray-200">
|
||||||
{/if}
|
<div
|
||||||
</div>
|
class="h-2 rounded-full bg-blue-600 transition-all"
|
||||||
</td>
|
style="width: {job.progress}%"
|
||||||
</tr>
|
></div>
|
||||||
{/each}
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if job.totalFiles > 0}
|
||||||
|
<div class="text-xs text-gray-400">
|
||||||
|
{job.processedFiles}/{job.totalFiles} files processed
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||||
|
{formatDate(job.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
{#if pendingCancelJobId === job.id}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => void runJobAction(job, 'cancel')}
|
||||||
|
disabled={isRowBusy(job.id)}
|
||||||
|
class="rounded bg-red-600 px-3 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{rowActions[job.id] === 'cancel' ? 'Cancelling...' : 'Confirm cancel'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => requestCancel(job.id)}
|
||||||
|
disabled={isRowBusy(job.id)}
|
||||||
|
class="rounded border border-gray-300 px-3 py-1 text-xs font-semibold text-gray-700 hover:border-gray-400 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Keep job
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
{#if canPause(job.status)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => void runJobAction(job, 'pause')}
|
||||||
|
disabled={isRowBusy(job.id)}
|
||||||
|
class="rounded bg-yellow-600 px-3 py-1 text-xs font-semibold text-white hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{rowActions[job.id] === 'pause' ? 'Pausing...' : 'Pause'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if canResume(job.status)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => void runJobAction(job, 'resume')}
|
||||||
|
disabled={isRowBusy(job.id)}
|
||||||
|
class="rounded bg-green-600 px-3 py-1 text-xs font-semibold text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{rowActions[job.id] === 'resume' ? 'Resuming...' : 'Resume'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if canCancel(job.status)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => requestCancel(job.id)}
|
||||||
|
disabled={isRowBusy(job.id)}
|
||||||
|
class="rounded bg-red-600 px-3 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if !canPause(job.status) && !canResume(job.status) && !canCancel(job.status)}
|
||||||
|
<span class="text-xs text-gray-400">—</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if refreshing}
|
||||||
<div class="mt-4 text-center text-sm text-gray-500">Refreshing...</div>
|
<div class="mt-4 text-center text-sm text-gray-500">Refreshing...</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Toast bind:toasts />
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ function getServices(db: ReturnType<typeof getClient>) {
|
|||||||
|
|
||||||
// Load the active embedding profile from the database
|
// Load the active embedding profile from the database
|
||||||
const profileRow = db
|
const profileRow = db
|
||||||
.prepare<[], EmbeddingProfileEntityProps>(
|
.prepare<
|
||||||
'SELECT * FROM embedding_profiles WHERE is_default = 1 AND enabled = 1 LIMIT 1'
|
[],
|
||||||
)
|
EmbeddingProfileEntityProps
|
||||||
|
>('SELECT * FROM embedding_profiles WHERE is_default = 1 AND enabled = 1 LIMIT 1')
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
const profile = profileRow
|
const profile = profileRow
|
||||||
@@ -227,10 +228,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
// Fall back to commit hash prefix match (min 7 chars).
|
// Fall back to commit hash prefix match (min 7 chars).
|
||||||
if (!resolvedVersion && parsed.version.length >= 7) {
|
if (!resolvedVersion && parsed.version.length >= 7) {
|
||||||
resolvedVersion = db
|
resolvedVersion = db
|
||||||
.prepare<
|
.prepare<[string, string], RawVersionRow>(
|
||||||
[string, string],
|
|
||||||
RawVersionRow
|
|
||||||
>(
|
|
||||||
`SELECT id, tag FROM repository_versions
|
`SELECT id, tag FROM repository_versions
|
||||||
WHERE repository_id = ? AND commit_hash LIKE ?`
|
WHERE repository_id = ? AND commit_hash LIKE ?`
|
||||||
)
|
)
|
||||||
@@ -261,14 +259,14 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
|
|
||||||
const selectedResults = applyTokenBudget
|
const selectedResults = applyTokenBudget
|
||||||
? (() => {
|
? (() => {
|
||||||
const snippets = searchResults.map((r) => r.snippet);
|
const snippets = searchResults.map((r) => r.snippet);
|
||||||
const selected = selectSnippetsWithinBudget(snippets, maxTokens);
|
const selected = selectSnippetsWithinBudget(snippets, maxTokens);
|
||||||
|
|
||||||
return selected.map((snippet) => {
|
return selected.map((snippet) => {
|
||||||
const found = searchResults.find((r) => r.snippet.id === snippet.id)!;
|
const found = searchResults.find((r) => r.snippet.id === snippet.id)!;
|
||||||
return found;
|
return found;
|
||||||
});
|
});
|
||||||
})()
|
})()
|
||||||
: searchResults;
|
: searchResults;
|
||||||
|
|
||||||
const snippetVersionIds = Array.from(
|
const snippetVersionIds = Array.from(
|
||||||
|
|||||||
@@ -15,15 +15,45 @@ import { JobQueue } from '$lib/server/pipeline/job-queue.js';
|
|||||||
import { handleServiceError } from '$lib/server/utils/validation.js';
|
import { handleServiceError } from '$lib/server/utils/validation.js';
|
||||||
import type { IndexingJob } from '$lib/types';
|
import type { IndexingJob } from '$lib/types';
|
||||||
|
|
||||||
|
const VALID_JOB_STATUSES: ReadonlySet<IndexingJob['status']> = new Set([
|
||||||
|
'queued',
|
||||||
|
'running',
|
||||||
|
'done',
|
||||||
|
'failed'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parseStatusFilter(
|
||||||
|
searchValue: string | null
|
||||||
|
): IndexingJob['status'] | Array<IndexingJob['status']> | undefined {
|
||||||
|
if (!searchValue) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses = [
|
||||||
|
...new Set(
|
||||||
|
searchValue
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value): value is IndexingJob['status'] =>
|
||||||
|
VALID_JOB_STATUSES.has(value as IndexingJob['status'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (statuses.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses.length === 1 ? statuses[0] : statuses;
|
||||||
|
}
|
||||||
|
|
||||||
export const GET: RequestHandler = ({ url }) => {
|
export const GET: RequestHandler = ({ url }) => {
|
||||||
try {
|
try {
|
||||||
const db = getClient();
|
const db = getClient();
|
||||||
const queue = new JobQueue(db);
|
const queue = new JobQueue(db);
|
||||||
|
|
||||||
const repositoryId = url.searchParams.get('repositoryId') ?? undefined;
|
const repositoryId = url.searchParams.get('repositoryId')?.trim() || undefined;
|
||||||
const status = (url.searchParams.get('status') ?? undefined) as
|
const status = parseStatusFilter(url.searchParams.get('status'));
|
||||||
| IndexingJob['status']
|
|
||||||
| undefined;
|
|
||||||
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '20', 10) || 20, 1000);
|
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '20', 10) || 20, 1000);
|
||||||
|
|
||||||
const jobs = queue.listJobs({ repositoryId, status, limit });
|
const jobs = queue.listJobs({ repositoryId, status, limit });
|
||||||
|
|||||||
@@ -44,19 +44,28 @@ export const GET: RequestHandler = ({ params, request }) => {
|
|||||||
status: job.status,
|
status: job.status,
|
||||||
error: job.error
|
error: job.error
|
||||||
};
|
};
|
||||||
controller.enqueue(`data: ${JSON.stringify(initialData)}\n\n`);
|
controller.enqueue(`event: job-progress\ndata: ${JSON.stringify(initialData)}\n\n`);
|
||||||
|
|
||||||
// Check for Last-Event-ID header for reconnect
|
// Check for Last-Event-ID header for reconnect
|
||||||
const lastEventId = request.headers.get('Last-Event-ID');
|
const lastEventId = request.headers.get('Last-Event-ID');
|
||||||
if (lastEventId) {
|
if (lastEventId) {
|
||||||
const lastEvent = broadcaster.getLastEvent(jobId);
|
const lastEvent = broadcaster.getLastEvent(jobId);
|
||||||
if (lastEvent && lastEvent.id >= parseInt(lastEventId, 10)) {
|
if (lastEvent && lastEvent.id >= parseInt(lastEventId, 10)) {
|
||||||
controller.enqueue(`id: ${lastEvent.id}\nevent: ${lastEvent.event}\ndata: ${lastEvent.data}\n\n`);
|
controller.enqueue(
|
||||||
|
`id: ${lastEvent.id}\nevent: ${lastEvent.event}\ndata: ${lastEvent.data}\n\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if job is already done or failed - close immediately after first event
|
// Check if job is already done or failed - close immediately after first event
|
||||||
if (job.status === 'done' || job.status === 'failed') {
|
if (job.status === 'done' || job.status === 'failed') {
|
||||||
|
if (job.status === 'done') {
|
||||||
|
controller.enqueue(`event: job-done\ndata: ${JSON.stringify({ jobId })}\n\n`);
|
||||||
|
} else {
|
||||||
|
controller.enqueue(
|
||||||
|
`event: job-failed\ndata: ${JSON.stringify({ jobId, error: job.error })}\n\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
controller.close();
|
controller.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -73,18 +82,26 @@ export const GET: RequestHandler = ({ params, request }) => {
|
|||||||
controller.enqueue(value);
|
controller.enqueue(value);
|
||||||
|
|
||||||
// Check if the incoming event indicates job completion
|
// Check if the incoming event indicates job completion
|
||||||
if (value.includes('event: done') || value.includes('event: failed')) {
|
if (value.includes('event: job-done') || value.includes('event: job-failed')) {
|
||||||
controller.close();
|
controller.close();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
controller.close();
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// Stream may already be closed after a terminal event.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('SSE stream error:', err);
|
console.error('SSE stream error:', err);
|
||||||
controller.close();
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// Stream may already be closed.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -93,7 +110,7 @@ export const GET: RequestHandler = ({ params, request }) => {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
'Connection': 'keep-alive',
|
Connection: 'keep-alive',
|
||||||
'X-Accel-Buffering': 'no',
|
'X-Accel-Buffering': 'no',
|
||||||
'Access-Control-Allow-Origin': '*'
|
'Access-Control-Allow-Origin': '*'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const GET: RequestHandler = ({ url }) => {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
'Connection': 'keep-alive',
|
Connection: 'keep-alive',
|
||||||
'X-Accel-Buffering': 'no',
|
'X-Accel-Buffering': 'no',
|
||||||
'Access-Control-Allow-Origin': '*'
|
'Access-Control-Allow-Origin': '*'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,9 +124,11 @@ describe('POST /api/v1/libs/:id/index', () => {
|
|||||||
versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0');
|
versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0');
|
||||||
versionService.add('/facebook/react', 'v17.0.0', 'React v17.0.0');
|
versionService.add('/facebook/react', 'v17.0.0', 'React v17.0.0');
|
||||||
|
|
||||||
const enqueue = vi.fn().mockImplementation(
|
const enqueue = vi
|
||||||
(repositoryId: string, versionId?: string) => makeEnqueueJob(repositoryId, versionId)
|
.fn()
|
||||||
);
|
.mockImplementation((repositoryId: string, versionId?: string) =>
|
||||||
|
makeEnqueueJob(repositoryId, versionId)
|
||||||
|
);
|
||||||
mockQueue = { enqueue };
|
mockQueue = { enqueue };
|
||||||
|
|
||||||
const response = await postIndex({
|
const response = await postIndex({
|
||||||
@@ -158,9 +160,11 @@ describe('POST /api/v1/libs/:id/index', () => {
|
|||||||
repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
|
repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
|
||||||
versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0');
|
versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0');
|
||||||
|
|
||||||
const enqueue = vi.fn().mockImplementation(
|
const enqueue = vi
|
||||||
(repositoryId: string, versionId?: string) => makeEnqueueJob(repositoryId, versionId)
|
.fn()
|
||||||
);
|
.mockImplementation((repositoryId: string, versionId?: string) =>
|
||||||
|
makeEnqueueJob(repositoryId, versionId)
|
||||||
|
);
|
||||||
mockQueue = { enqueue };
|
mockQueue = { enqueue };
|
||||||
|
|
||||||
const response = await postIndex({
|
const response = await postIndex({
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ function createTestDb(): Database.Database {
|
|||||||
const client = new Database(':memory:');
|
const client = new Database(':memory:');
|
||||||
client.pragma('foreign_keys = ON');
|
client.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
const migrationsFolder = join(import.meta.dirname, '../../../../../../../lib/server/db/migrations');
|
const migrationsFolder = join(
|
||||||
|
import.meta.dirname,
|
||||||
|
'../../../../../../../lib/server/db/migrations'
|
||||||
|
);
|
||||||
const ftsFile = join(import.meta.dirname, '../../../../../../../lib/server/db/fts.sql');
|
const ftsFile = join(import.meta.dirname, '../../../../../../../lib/server/db/fts.sql');
|
||||||
|
|
||||||
const migration0 = readFileSync(join(migrationsFolder, '0000_large_master_chief.sql'), 'utf-8');
|
const migration0 = readFileSync(join(migrationsFolder, '0000_large_master_chief.sql'), 'utf-8');
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ export const GET: RequestHandler = () => {
|
|||||||
try {
|
try {
|
||||||
const db = getClient();
|
const db = getClient();
|
||||||
const row = db
|
const row = db
|
||||||
.prepare<[], { value: string }>(
|
.prepare<
|
||||||
"SELECT value FROM settings WHERE key = 'indexing.concurrency'"
|
[],
|
||||||
)
|
{ value: string }
|
||||||
|
>("SELECT value FROM settings WHERE key = 'indexing.concurrency'")
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
let concurrency = 2;
|
let concurrency = 2;
|
||||||
@@ -54,13 +55,13 @@ export const PUT: RequestHandler = async ({ request }) => {
|
|||||||
|
|
||||||
// Validate and clamp concurrency
|
// Validate and clamp concurrency
|
||||||
const maxConcurrency = Math.max(os.cpus().length - 1, 1);
|
const maxConcurrency = Math.max(os.cpus().length - 1, 1);
|
||||||
const concurrency = Math.max(1, Math.min(parseInt(String(body.concurrency ?? 2), 10), maxConcurrency));
|
const concurrency = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(parseInt(String(body.concurrency ?? 2), 10), maxConcurrency)
|
||||||
|
);
|
||||||
|
|
||||||
if (isNaN(concurrency)) {
|
if (isNaN(concurrency)) {
|
||||||
return json(
|
return json({ error: 'Concurrency must be a valid integer' }, { status: 400 });
|
||||||
{ error: 'Concurrency must be a valid integer' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getClient();
|
const db = getClient();
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import type { ProgressBroadcaster as BroadcasterType } from '$lib/server/pipelin
|
|||||||
let db: Database.Database;
|
let db: Database.Database;
|
||||||
// Closed over by the vi.mock factory below.
|
// Closed over by the vi.mock factory below.
|
||||||
let mockBroadcaster: BroadcasterType | null = null;
|
let mockBroadcaster: BroadcasterType | null = null;
|
||||||
|
let mockPool: { getStatus: () => object; setMaxConcurrency?: (value: number) => void } | null =
|
||||||
|
null;
|
||||||
|
|
||||||
vi.mock('$lib/server/db/client', () => ({
|
vi.mock('$lib/server/db/client', () => ({
|
||||||
getClient: () => db
|
getClient: () => db
|
||||||
@@ -29,16 +31,17 @@ vi.mock('$lib/server/db/client.js', () => ({
|
|||||||
|
|
||||||
vi.mock('$lib/server/pipeline/startup', () => ({
|
vi.mock('$lib/server/pipeline/startup', () => ({
|
||||||
getQueue: () => null,
|
getQueue: () => null,
|
||||||
getPool: () => null
|
getPool: () => mockPool
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/server/pipeline/startup.js', () => ({
|
vi.mock('$lib/server/pipeline/startup.js', () => ({
|
||||||
getQueue: () => null,
|
getQueue: () => null,
|
||||||
getPool: () => null
|
getPool: () => mockPool
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/server/pipeline/progress-broadcaster', async (importOriginal) => {
|
vi.mock('$lib/server/pipeline/progress-broadcaster', async (importOriginal) => {
|
||||||
const original = await importOriginal<typeof import('$lib/server/pipeline/progress-broadcaster.js')>();
|
const original =
|
||||||
|
await importOriginal<typeof import('$lib/server/pipeline/progress-broadcaster.js')>();
|
||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
getBroadcaster: () => mockBroadcaster
|
getBroadcaster: () => mockBroadcaster
|
||||||
@@ -46,7 +49,8 @@ vi.mock('$lib/server/pipeline/progress-broadcaster', async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('$lib/server/pipeline/progress-broadcaster.js', async (importOriginal) => {
|
vi.mock('$lib/server/pipeline/progress-broadcaster.js', async (importOriginal) => {
|
||||||
const original = await importOriginal<typeof import('$lib/server/pipeline/progress-broadcaster.js')>();
|
const original =
|
||||||
|
await importOriginal<typeof import('$lib/server/pipeline/progress-broadcaster.js')>();
|
||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
getBroadcaster: () => mockBroadcaster
|
getBroadcaster: () => mockBroadcaster
|
||||||
@@ -58,9 +62,14 @@ vi.mock('$lib/server/pipeline/progress-broadcaster.js', async (importOriginal) =
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { ProgressBroadcaster } from '$lib/server/pipeline/progress-broadcaster.js';
|
import { ProgressBroadcaster } from '$lib/server/pipeline/progress-broadcaster.js';
|
||||||
|
import { GET as getJobsList } from './jobs/+server.js';
|
||||||
import { GET as getJobStream } from './jobs/[id]/stream/+server.js';
|
import { GET as getJobStream } from './jobs/[id]/stream/+server.js';
|
||||||
import { GET as getJobsStream } from './jobs/stream/+server.js';
|
import { GET as getJobsStream } from './jobs/stream/+server.js';
|
||||||
import { GET as getIndexingSettings, PUT as putIndexingSettings } from './settings/indexing/+server.js';
|
import {
|
||||||
|
GET as getIndexingSettings,
|
||||||
|
PUT as putIndexingSettings
|
||||||
|
} from './settings/indexing/+server.js';
|
||||||
|
import { GET as getWorkers } from './workers/+server.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DB factory
|
// DB factory
|
||||||
@@ -81,7 +90,10 @@ function createTestDb(): Database.Database {
|
|||||||
'0005_fix_stage_defaults.sql'
|
'0005_fix_stage_defaults.sql'
|
||||||
]) {
|
]) {
|
||||||
const sql = readFileSync(join(migrationsFolder, migrationFile), 'utf-8');
|
const sql = readFileSync(join(migrationsFolder, migrationFile), 'utf-8');
|
||||||
for (const stmt of sql.split('--> statement-breakpoint').map((s) => s.trim()).filter(Boolean)) {
|
for (const stmt of sql
|
||||||
|
.split('--> statement-breakpoint')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)) {
|
||||||
client.exec(stmt);
|
client.exec(stmt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,9 +210,7 @@ describe('GET /api/v1/jobs/:id/stream', () => {
|
|||||||
it('returns 404 when the job does not exist', async () => {
|
it('returns 404 when the job does not exist', async () => {
|
||||||
seedRepo(db);
|
seedRepo(db);
|
||||||
|
|
||||||
const response = await getJobStream(
|
const response = await getJobStream(makeEvent({ params: { id: 'non-existent-job-id' } }));
|
||||||
makeEvent({ params: { id: 'non-existent-job-id' } })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
});
|
});
|
||||||
@@ -306,6 +316,25 @@ describe('GET /api/v1/jobs/:id/stream', () => {
|
|||||||
// The replay event should include the cached event data
|
// The replay event should include the cached event data
|
||||||
expect(fullText).toContain('progress');
|
expect(fullText).toContain('progress');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('closes after receiving the broadcaster job-done event', async () => {
|
||||||
|
seedRepo(db);
|
||||||
|
const jobId = seedJob(db, { status: 'running', stage: 'parsing', progress: 10 });
|
||||||
|
|
||||||
|
const response = await getJobStream(makeEvent({ params: { id: jobId } }));
|
||||||
|
const reader = response.body!.getReader();
|
||||||
|
|
||||||
|
const initialChunk = await reader.read();
|
||||||
|
expect(String(initialChunk.value ?? '')).toContain('event: job-progress');
|
||||||
|
|
||||||
|
mockBroadcaster!.broadcast(jobId, '/test/repo', 'job-done', { jobId, status: 'done' });
|
||||||
|
|
||||||
|
const completionChunk = await reader.read();
|
||||||
|
expect(String(completionChunk.value ?? '')).toContain('event: job-done');
|
||||||
|
|
||||||
|
const closed = await reader.read();
|
||||||
|
expect(closed.done).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -341,7 +370,9 @@ describe('GET /api/v1/jobs/stream', () => {
|
|||||||
const subscribeSpy = vi.spyOn(mockBroadcaster!, 'subscribeRepository');
|
const subscribeSpy = vi.spyOn(mockBroadcaster!, 'subscribeRepository');
|
||||||
|
|
||||||
await getJobsStream(
|
await getJobsStream(
|
||||||
makeEvent<Parameters<typeof getJobsStream>[0]>({ url: 'http://localhost/api/v1/jobs/stream?repositoryId=/test/repo' })
|
makeEvent<Parameters<typeof getJobsStream>[0]>({
|
||||||
|
url: 'http://localhost/api/v1/jobs/stream?repositoryId=/test/repo'
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(subscribeSpy).toHaveBeenCalledWith('/test/repo');
|
expect(subscribeSpy).toHaveBeenCalledWith('/test/repo');
|
||||||
@@ -361,7 +392,9 @@ describe('GET /api/v1/jobs/stream', () => {
|
|||||||
seedRepo(db, '/repo/alpha');
|
seedRepo(db, '/repo/alpha');
|
||||||
|
|
||||||
const response = await getJobsStream(
|
const response = await getJobsStream(
|
||||||
makeEvent<Parameters<typeof getJobsStream>[0]>({ url: 'http://localhost/api/v1/jobs/stream?repositoryId=/repo/alpha' })
|
makeEvent<Parameters<typeof getJobsStream>[0]>({
|
||||||
|
url: 'http://localhost/api/v1/jobs/stream?repositoryId=/repo/alpha'
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast an event for this repository
|
// Broadcast an event for this repository
|
||||||
@@ -377,16 +410,131 @@ describe('GET /api/v1/jobs/stream', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Test group 3: GET /api/v1/settings/indexing
|
// Test group 3: GET /api/v1/jobs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/v1/jobs', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports repository prefix and comma-separated status filters', async () => {
|
||||||
|
seedRepo(db, '/facebook/react');
|
||||||
|
seedRepo(db, '/facebook/react-native');
|
||||||
|
seedRepo(db, '/vitejs/vite');
|
||||||
|
|
||||||
|
seedJob(db, { repository_id: '/facebook/react', status: 'queued' });
|
||||||
|
seedJob(db, { repository_id: '/facebook/react-native', status: 'running' });
|
||||||
|
seedJob(db, { repository_id: '/facebook/react', status: 'done' });
|
||||||
|
seedJob(db, { repository_id: '/vitejs/vite', status: 'queued' });
|
||||||
|
|
||||||
|
const response = await getJobsList(
|
||||||
|
makeEvent<Parameters<typeof getJobsList>[0]>({
|
||||||
|
url: 'http://localhost/api/v1/jobs?repositoryId=%2Ffacebook&status=queued,%20running'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.total).toBe(2);
|
||||||
|
expect(body.jobs).toHaveLength(2);
|
||||||
|
expect(body.jobs.map((job: { repositoryId: string }) => job.repositoryId).sort()).toEqual([
|
||||||
|
'/facebook/react',
|
||||||
|
'/facebook/react-native'
|
||||||
|
]);
|
||||||
|
expect(body.jobs.map((job: { status: string }) => job.status).sort()).toEqual([
|
||||||
|
'queued',
|
||||||
|
'running'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps exact-match behavior for specific repository IDs', async () => {
|
||||||
|
seedRepo(db, '/facebook/react');
|
||||||
|
seedRepo(db, '/facebook/react-native');
|
||||||
|
|
||||||
|
seedJob(db, { repository_id: '/facebook/react', status: 'queued' });
|
||||||
|
seedJob(db, { repository_id: '/facebook/react-native', status: 'queued' });
|
||||||
|
|
||||||
|
const response = await getJobsList(
|
||||||
|
makeEvent<Parameters<typeof getJobsList>[0]>({
|
||||||
|
url: 'http://localhost/api/v1/jobs?repositoryId=%2Ffacebook%2Freact&status=queued'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.total).toBe(1);
|
||||||
|
expect(body.jobs).toHaveLength(1);
|
||||||
|
expect(body.jobs[0].repositoryId).toBe('/facebook/react');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test group 4: GET /api/v1/workers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/v1/workers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPool = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 when the worker pool is not initialized', async () => {
|
||||||
|
const response = await getWorkers(makeEvent<Parameters<typeof getWorkers>[0]>({}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the current worker status snapshot', async () => {
|
||||||
|
mockPool = {
|
||||||
|
getStatus: () => ({
|
||||||
|
concurrency: 2,
|
||||||
|
active: 1,
|
||||||
|
idle: 1,
|
||||||
|
workers: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
state: 'running',
|
||||||
|
jobId: 'job-1',
|
||||||
|
repositoryId: '/test/repo',
|
||||||
|
versionId: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
state: 'idle',
|
||||||
|
jobId: null,
|
||||||
|
repositoryId: null,
|
||||||
|
versionId: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await getWorkers(makeEvent<Parameters<typeof getWorkers>[0]>({}));
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.active).toBe(1);
|
||||||
|
expect(body.workers[0].jobId).toBe('job-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test group 5: GET /api/v1/settings/indexing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('GET /api/v1/settings/indexing', () => {
|
describe('GET /api/v1/settings/indexing', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
db = createTestDb();
|
db = createTestDb();
|
||||||
|
mockPool = {
|
||||||
|
getStatus: () => ({ concurrency: 2, active: 0, idle: 2, workers: [] }),
|
||||||
|
setMaxConcurrency: vi.fn()
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns { concurrency: 2 } when no setting exists in DB', async () => {
|
it('returns { concurrency: 2 } when no setting exists in DB', async () => {
|
||||||
const response = await getIndexingSettings(makeEvent<Parameters<typeof getIndexingSettings>[0]>({}));
|
const response = await getIndexingSettings(
|
||||||
|
makeEvent<Parameters<typeof getIndexingSettings>[0]>({})
|
||||||
|
);
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
@@ -398,7 +546,9 @@ describe('GET /api/v1/settings/indexing', () => {
|
|||||||
"INSERT INTO settings (key, value, updated_at) VALUES ('indexing.concurrency', ?, ?)"
|
"INSERT INTO settings (key, value, updated_at) VALUES ('indexing.concurrency', ?, ?)"
|
||||||
).run(JSON.stringify(4), NOW_S);
|
).run(JSON.stringify(4), NOW_S);
|
||||||
|
|
||||||
const response = await getIndexingSettings(makeEvent<Parameters<typeof getIndexingSettings>[0]>({}));
|
const response = await getIndexingSettings(
|
||||||
|
makeEvent<Parameters<typeof getIndexingSettings>[0]>({})
|
||||||
|
);
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
expect(body.concurrency).toBe(4);
|
expect(body.concurrency).toBe(4);
|
||||||
@@ -409,7 +559,9 @@ describe('GET /api/v1/settings/indexing', () => {
|
|||||||
"INSERT INTO settings (key, value, updated_at) VALUES ('indexing.concurrency', ?, ?)"
|
"INSERT INTO settings (key, value, updated_at) VALUES ('indexing.concurrency', ?, ?)"
|
||||||
).run(JSON.stringify({ value: 5 }), NOW_S);
|
).run(JSON.stringify({ value: 5 }), NOW_S);
|
||||||
|
|
||||||
const response = await getIndexingSettings(makeEvent<Parameters<typeof getIndexingSettings>[0]>({}));
|
const response = await getIndexingSettings(
|
||||||
|
makeEvent<Parameters<typeof getIndexingSettings>[0]>({})
|
||||||
|
);
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
expect(body.concurrency).toBe(5);
|
expect(body.concurrency).toBe(5);
|
||||||
@@ -417,12 +569,16 @@ describe('GET /api/v1/settings/indexing', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Test group 4: PUT /api/v1/settings/indexing
|
// Test group 6: PUT /api/v1/settings/indexing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('PUT /api/v1/settings/indexing', () => {
|
describe('PUT /api/v1/settings/indexing', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
db = createTestDb();
|
db = createTestDb();
|
||||||
|
mockPool = {
|
||||||
|
getStatus: () => ({ concurrency: 2, active: 0, idle: 2, workers: [] }),
|
||||||
|
setMaxConcurrency: vi.fn()
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function makePutEvent(body: unknown) {
|
function makePutEvent(body: unknown) {
|
||||||
@@ -461,9 +617,10 @@ describe('PUT /api/v1/settings/indexing', () => {
|
|||||||
await putIndexingSettings(makePutEvent({ concurrency: 3 }));
|
await putIndexingSettings(makePutEvent({ concurrency: 3 }));
|
||||||
|
|
||||||
const row = db
|
const row = db
|
||||||
.prepare<[], { value: string }>(
|
.prepare<
|
||||||
"SELECT value FROM settings WHERE key = 'indexing.concurrency'"
|
[],
|
||||||
)
|
{ value: string }
|
||||||
|
>("SELECT value FROM settings WHERE key = 'indexing.concurrency'")
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
expect(row).toBeDefined();
|
expect(row).toBeDefined();
|
||||||
@@ -495,9 +652,7 @@ describe('PUT /api/v1/settings/indexing', () => {
|
|||||||
// The actual flow: parseInt('abc') => NaN, Math.max(1, Math.min(NaN, max)) => NaN,
|
// The actual flow: parseInt('abc') => NaN, Math.max(1, Math.min(NaN, max)) => NaN,
|
||||||
// then `if (isNaN(concurrency))` returns 400.
|
// then `if (isNaN(concurrency))` returns 400.
|
||||||
// We pass the raw string directly.
|
// We pass the raw string directly.
|
||||||
const response = await putIndexingSettings(
|
const response = await putIndexingSettings(makePutEvent({ concurrency: 'not-a-number' }));
|
||||||
makePutEvent({ concurrency: 'not-a-number' })
|
|
||||||
);
|
|
||||||
|
|
||||||
// parseInt('not-a-number') = NaN, so the handler should return 400
|
// parseInt('not-a-number') = NaN, so the handler should return 400
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
|
|||||||
16
src/routes/api/v1/workers/+server.ts
Normal file
16
src/routes/api/v1/workers/+server.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getPool } from '$lib/server/pipeline/startup.js';
|
||||||
|
import { handleServiceError } from '$lib/server/utils/validation.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = () => {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
if (!pool) {
|
||||||
|
return new Response('Service unavailable', { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(pool.getStatus());
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -39,8 +39,11 @@
|
|||||||
indexedAt: string | null;
|
indexedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
type VersionStateFilter = VersionDto['state'] | 'all';
|
||||||
let versions = $state<VersionDto[]>([]);
|
let versions = $state<VersionDto[]>([]);
|
||||||
let versionsLoading = $state(false);
|
let versionsLoading = $state(false);
|
||||||
|
let activeVersionFilter = $state<VersionStateFilter>('all');
|
||||||
|
let bulkReprocessBusy = $state(false);
|
||||||
|
|
||||||
// Add version form
|
// Add version form
|
||||||
let addVersionTag = $state('');
|
let addVersionTag = $state('');
|
||||||
@@ -49,7 +52,7 @@
|
|||||||
// Discover tags state
|
// Discover tags state
|
||||||
let discoverBusy = $state(false);
|
let discoverBusy = $state(false);
|
||||||
let discoveredTags = $state<Array<{ tag: string; commitHash: string }>>([]);
|
let discoveredTags = $state<Array<{ tag: string; commitHash: string }>>([]);
|
||||||
let selectedDiscoveredTags = new SvelteSet<string>();
|
const selectedDiscoveredTags = new SvelteSet<string>();
|
||||||
let showDiscoverPanel = $state(false);
|
let showDiscoverPanel = $state(false);
|
||||||
let registerBusy = $state(false);
|
let registerBusy = $state(false);
|
||||||
|
|
||||||
@@ -76,6 +79,14 @@
|
|||||||
error: 'Error'
|
error: 'Error'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const versionFilterOptions: Array<{ value: VersionStateFilter; label: string }> = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'pending', label: stateLabels.pending },
|
||||||
|
{ value: 'indexing', label: stateLabels.indexing },
|
||||||
|
{ value: 'indexed', label: stateLabels.indexed },
|
||||||
|
{ value: 'error', label: stateLabels.error }
|
||||||
|
];
|
||||||
|
|
||||||
const stageLabels: Record<string, string> = {
|
const stageLabels: Record<string, string> = {
|
||||||
queued: 'Queued',
|
queued: 'Queued',
|
||||||
differential: 'Diff',
|
differential: 'Diff',
|
||||||
@@ -88,6 +99,20 @@
|
|||||||
failed: 'Failed'
|
failed: 'Failed'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredVersions = $derived(
|
||||||
|
activeVersionFilter === 'all'
|
||||||
|
? versions
|
||||||
|
: versions.filter((version) => version.state === activeVersionFilter)
|
||||||
|
);
|
||||||
|
const actionableErroredTags = $derived(
|
||||||
|
versions
|
||||||
|
.filter((version) => version.state === 'error' && !activeVersionJobs[version.tag])
|
||||||
|
.map((version) => version.tag)
|
||||||
|
);
|
||||||
|
const activeVersionFilterLabel = $derived(
|
||||||
|
versionFilterOptions.find((option) => option.value === activeVersionFilter)?.label ?? 'All'
|
||||||
|
);
|
||||||
|
|
||||||
async function refreshRepo() {
|
async function refreshRepo() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}`);
|
const res = await fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}`);
|
||||||
@@ -123,9 +148,7 @@
|
|||||||
if (!repo.id) return;
|
if (!repo.id) return;
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
const es = new EventSource(
|
const es = new EventSource(`/api/v1/jobs/stream?repositoryId=${encodeURIComponent(repo.id)}`);
|
||||||
`/api/v1/jobs/stream?repositoryId=${encodeURIComponent(repo.id)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
es.addEventListener('job-progress', (event) => {
|
es.addEventListener('job-progress', (event) => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
@@ -277,23 +300,58 @@
|
|||||||
async function handleIndexVersion(tag: string) {
|
async function handleIndexVersion(tag: string) {
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const jobId = await queueVersionIndex(tag);
|
||||||
`/api/v1/libs/${encodeURIComponent(repo.id)}/versions/${encodeURIComponent(tag)}/index`,
|
if (jobId) {
|
||||||
{ method: 'POST' }
|
activeVersionJobs = { ...activeVersionJobs, [tag]: jobId };
|
||||||
);
|
|
||||||
if (!res.ok) {
|
|
||||||
const d = await res.json();
|
|
||||||
throw new Error(d.error ?? 'Failed to queue version indexing');
|
|
||||||
}
|
|
||||||
const d = await res.json();
|
|
||||||
if (d.job?.id) {
|
|
||||||
activeVersionJobs = { ...activeVersionJobs, [tag]: d.job.id };
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage = (e as Error).message;
|
errorMessage = (e as Error).message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queueVersionIndex(tag: string): Promise<string | null> {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/libs/${encodeURIComponent(repo.id)}/versions/${encodeURIComponent(tag)}/index`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
throw new Error(d.error ?? 'Failed to queue version indexing');
|
||||||
|
}
|
||||||
|
const d = await res.json();
|
||||||
|
return d.job?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBulkReprocessErroredVersions() {
|
||||||
|
if (actionableErroredTags.length === 0) return;
|
||||||
|
bulkReprocessBusy = true;
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
try {
|
||||||
|
const tags = [...actionableErroredTags];
|
||||||
|
const BATCH_SIZE = 5;
|
||||||
|
let next = { ...activeVersionJobs };
|
||||||
|
|
||||||
|
for (let i = 0; i < tags.length; i += BATCH_SIZE) {
|
||||||
|
const batch = tags.slice(i, i + BATCH_SIZE);
|
||||||
|
const jobIds = await Promise.all(batch.map((versionTag) => queueVersionIndex(versionTag)));
|
||||||
|
for (let j = 0; j < batch.length; j++) {
|
||||||
|
if (jobIds[j]) {
|
||||||
|
next = { ...next, [batch[j]]: jobIds[j] ?? undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeVersionJobs = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
successMessage = `Queued ${tags.length} errored tag${tags.length === 1 ? '' : 's'} for reprocessing.`;
|
||||||
|
await loadVersions();
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
bulkReprocessBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRemoveVersion() {
|
async function handleRemoveVersion() {
|
||||||
if (!removeTag) return;
|
if (!removeTag) return;
|
||||||
const tag = removeTag;
|
const tag = removeTag;
|
||||||
@@ -318,10 +376,9 @@
|
|||||||
discoverBusy = true;
|
discoverBusy = true;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}/versions/discover`, {
|
||||||
`/api/v1/libs/${encodeURIComponent(repo.id)}/versions/discover`,
|
method: 'POST'
|
||||||
{ method: 'POST' }
|
});
|
||||||
);
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
throw new Error(d.error ?? 'Failed to discover tags');
|
throw new Error(d.error ?? 'Failed to discover tags');
|
||||||
@@ -331,7 +388,10 @@
|
|||||||
discoveredTags = (d.tags ?? []).filter(
|
discoveredTags = (d.tags ?? []).filter(
|
||||||
(t: { tag: string; commitHash: string }) => !registeredTags.has(t.tag)
|
(t: { tag: string; commitHash: string }) => !registeredTags.has(t.tag)
|
||||||
);
|
);
|
||||||
selectedDiscoveredTags = new SvelteSet(discoveredTags.map((t) => t.tag));
|
selectedDiscoveredTags.clear();
|
||||||
|
for (const discoveredTag of discoveredTags) {
|
||||||
|
selectedDiscoveredTags.add(discoveredTag.tag);
|
||||||
|
}
|
||||||
showDiscoverPanel = true;
|
showDiscoverPanel = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage = (e as Error).message;
|
errorMessage = (e as Error).message;
|
||||||
@@ -380,7 +440,7 @@
|
|||||||
activeVersionJobs = next;
|
activeVersionJobs = next;
|
||||||
showDiscoverPanel = false;
|
showDiscoverPanel = false;
|
||||||
discoveredTags = [];
|
discoveredTags = [];
|
||||||
selectedDiscoveredTags = new SvelteSet();
|
selectedDiscoveredTags.clear();
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage = (e as Error).message;
|
errorMessage = (e as Error).message;
|
||||||
@@ -498,41 +558,69 @@
|
|||||||
|
|
||||||
<!-- Versions -->
|
<!-- Versions -->
|
||||||
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-5">
|
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-5">
|
||||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div class="mb-4 flex flex-col gap-3">
|
||||||
<h2 class="text-sm font-semibold text-gray-700">Versions</h2>
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<!-- Add version inline form -->
|
<h2 class="text-sm font-semibold text-gray-700">Versions</h2>
|
||||||
<form
|
<div class="flex flex-wrap items-center gap-1 rounded-lg bg-gray-100 p-1">
|
||||||
onsubmit={(e) => {
|
{#each versionFilterOptions as option (option.value)}
|
||||||
e.preventDefault();
|
<button
|
||||||
handleAddVersion();
|
type="button"
|
||||||
}}
|
onclick={() => (activeVersionFilter = option.value)}
|
||||||
class="flex items-center gap-1.5"
|
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors {activeVersionFilter ===
|
||||||
>
|
option.value
|
||||||
<input
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
type="text"
|
: 'text-gray-500 hover:text-gray-700'}"
|
||||||
bind:value={addVersionTag}
|
>
|
||||||
placeholder="e.g. v2.0.0"
|
{option.label}
|
||||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-400 focus:outline-none"
|
</button>
|
||||||
/>
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={addVersionBusy || !addVersionTag.trim()}
|
onclick={handleBulkReprocessErroredVersions}
|
||||||
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
disabled={bulkReprocessBusy || actionableErroredTags.length === 0}
|
||||||
|
class="rounded-lg border border-red-200 px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Add
|
{bulkReprocessBusy
|
||||||
|
? 'Reprocessing...'
|
||||||
|
: `Reprocess errored${actionableErroredTags.length > 0 ? ` (${actionableErroredTags.length})` : ''}`}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
<!-- Add version inline form -->
|
||||||
<!-- Discover tags button — local repos only -->
|
<form
|
||||||
{#if repo.source === 'local'}
|
onsubmit={(e) => {
|
||||||
<button
|
e.preventDefault();
|
||||||
onclick={handleDiscoverTags}
|
handleAddVersion();
|
||||||
disabled={discoverBusy}
|
}}
|
||||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
class="flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{discoverBusy ? 'Discovering...' : 'Discover tags'}
|
<input
|
||||||
</button>
|
type="text"
|
||||||
{/if}
|
bind:value={addVersionTag}
|
||||||
|
placeholder="e.g. v2.0.0"
|
||||||
|
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={addVersionBusy || !addVersionTag.trim()}
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<!-- Discover tags button — local repos only -->
|
||||||
|
{#if repo.source === 'local'}
|
||||||
|
<button
|
||||||
|
onclick={handleDiscoverTags}
|
||||||
|
disabled={discoverBusy}
|
||||||
|
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{discoverBusy ? 'Discovering...' : 'Discover tags'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -549,7 +637,7 @@
|
|||||||
onclick={() => {
|
onclick={() => {
|
||||||
showDiscoverPanel = false;
|
showDiscoverPanel = false;
|
||||||
discoveredTags = [];
|
discoveredTags = [];
|
||||||
selectedDiscoveredTags = new SvelteSet();
|
selectedDiscoveredTags.clear();
|
||||||
}}
|
}}
|
||||||
class="text-xs text-blue-600 hover:underline"
|
class="text-xs text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
@@ -567,7 +655,9 @@
|
|||||||
class="rounded border-gray-300"
|
class="rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<span class="font-mono text-gray-800">{discovered.tag}</span>
|
<span class="font-mono text-gray-800">{discovered.tag}</span>
|
||||||
<span class="font-mono text-xs text-gray-400">{discovered.commitHash.slice(0, 8)}</span>
|
<span class="font-mono text-xs text-gray-400"
|
||||||
|
>{discovered.commitHash.slice(0, 8)}</span
|
||||||
|
>
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -576,9 +666,7 @@
|
|||||||
disabled={registerBusy || selectedDiscoveredTags.size === 0}
|
disabled={registerBusy || selectedDiscoveredTags.size === 0}
|
||||||
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{registerBusy
|
{registerBusy ? 'Registering...' : `Register ${selectedDiscoveredTags.size} selected`}
|
||||||
? 'Registering...'
|
|
||||||
: `Register ${selectedDiscoveredTags.size} selected`}
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -589,9 +677,15 @@
|
|||||||
<p class="text-sm text-gray-400">Loading versions...</p>
|
<p class="text-sm text-gray-400">Loading versions...</p>
|
||||||
{:else if versions.length === 0}
|
{:else if versions.length === 0}
|
||||||
<p class="text-sm text-gray-400">No versions registered. Add a tag above to get started.</p>
|
<p class="text-sm text-gray-400">No versions registered. Add a tag above to get started.</p>
|
||||||
|
{:else if filteredVersions.length === 0}
|
||||||
|
<div class="rounded-lg border border-dashed border-gray-200 bg-gray-50 px-4 py-5">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
No versions match the {activeVersionFilterLabel.toLowerCase()} filter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{#each versions as version (version.id)}
|
{#each filteredVersions as version (version.id)}
|
||||||
<div class="py-2.5">
|
<div class="py-2.5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -609,7 +703,9 @@
|
|||||||
disabled={version.state === 'indexing' || !!activeVersionJobs[version.tag]}
|
disabled={version.state === 'indexing' || !!activeVersionJobs[version.tag]}
|
||||||
class="rounded-lg border border-blue-200 px-3 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
class="rounded-lg border border-blue-200 px-3 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{version.state === 'indexing' || !!activeVersionJobs[version.tag] ? 'Indexing...' : 'Index'}
|
{version.state === 'indexing' || !!activeVersionJobs[version.tag]
|
||||||
|
? 'Indexing...'
|
||||||
|
: 'Index'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (removeTag = version.tag)}
|
onclick={() => (removeTag = version.tag)}
|
||||||
@@ -625,12 +721,8 @@
|
|||||||
version.totalSnippets > 0
|
version.totalSnippets > 0
|
||||||
? { text: `${version.totalSnippets} snippets`, mono: false }
|
? { text: `${version.totalSnippets} snippets`, mono: false }
|
||||||
: null,
|
: null,
|
||||||
version.commitHash
|
version.commitHash ? { text: version.commitHash.slice(0, 8), mono: true } : null,
|
||||||
? { text: version.commitHash.slice(0, 8), mono: true }
|
version.indexedAt ? { text: formatDate(version.indexedAt), mono: false } : null
|
||||||
: null,
|
|
||||||
version.indexedAt
|
|
||||||
? { text: formatDate(version.indexedAt), mono: false }
|
|
||||||
: null
|
|
||||||
] as Array<{ text: string; mono: boolean } | null>
|
] as Array<{ text: string; mono: boolean } | null>
|
||||||
).filter((p): p is { text: string; mono: boolean } => p !== null)}
|
).filter((p): p is { text: string; mono: boolean } => p !== null)}
|
||||||
<div class="mt-1 flex items-center gap-1.5">
|
<div class="mt-1 flex items-center gap-1.5">
|
||||||
@@ -638,7 +730,8 @@
|
|||||||
{#if i > 0}
|
{#if i > 0}
|
||||||
<span class="text-xs text-gray-300">·</span>
|
<span class="text-xs text-gray-300">·</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="text-xs text-gray-400{part.mono ? ' font-mono' : ''}">{part.text}</span>
|
<span class="text-xs text-gray-400{part.mono ? ' font-mono' : ''}">{part.text}</span
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -646,10 +739,12 @@
|
|||||||
{@const job = versionJobProgress[activeVersionJobs[version.tag]!]}
|
{@const job = versionJobProgress[activeVersionJobs[version.tag]!]}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div class="flex justify-between text-xs text-gray-500">
|
<div class="flex justify-between text-xs text-gray-500">
|
||||||
<span>
|
<span>
|
||||||
{#if job?.stageDetail}{job.stageDetail}{:else}{(job?.processedFiles ?? 0).toLocaleString()} / {(job?.totalFiles ?? 0).toLocaleString()} files{/if}
|
{#if job?.stageDetail}{job.stageDetail}{:else}{(
|
||||||
{#if job?.stage}{' - ' + (stageLabels[job.stage] ?? job.stage)}{/if}
|
job?.processedFiles ?? 0
|
||||||
</span>
|
).toLocaleString()} / {(job?.totalFiles ?? 0).toLocaleString()} files{/if}
|
||||||
|
{#if job?.stage}{' - ' + (stageLabels[job.stage] ?? job.stage)}{/if}
|
||||||
|
</span>
|
||||||
<span>{job?.progress ?? 0}%</span>
|
<span>{job?.progress ?? 0}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 h-1.5 w-full rounded-full bg-gray-200">
|
<div class="mt-1 h-1.5 w-full rounded-full bg-gray-200">
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ export const load: PageServerLoad = async () => {
|
|||||||
// Read indexing concurrency setting
|
// Read indexing concurrency setting
|
||||||
let indexingConcurrency = 2;
|
let indexingConcurrency = 2;
|
||||||
const concurrencyRow = db
|
const concurrencyRow = db
|
||||||
.prepare<[], { value: string }>(
|
.prepare<[], { value: string }>("SELECT value FROM settings WHERE key = 'indexing.concurrency'")
|
||||||
"SELECT value FROM settings WHERE key = 'indexing.concurrency'"
|
|
||||||
)
|
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (concurrencyRow && concurrencyRow.value) {
|
if (concurrencyRow && concurrencyRow.value) {
|
||||||
|
|||||||
@@ -199,7 +199,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getOpenAiProfile(settings: EmbeddingSettingsDto): EmbeddingProfileDto | null {
|
function getOpenAiProfile(settings: EmbeddingSettingsDto): EmbeddingProfileDto | null {
|
||||||
return settings.profiles.find((profile) => profile.providerKind === 'openai-compatible') ?? null;
|
return (
|
||||||
|
settings.profiles.find((profile) => profile.providerKind === 'openai-compatible') ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProvider(profile: EmbeddingProfileDto | null): 'none' | 'openai' | 'local' {
|
function resolveProvider(profile: EmbeddingProfileDto | null): 'none' | 'openai' | 'local' {
|
||||||
@@ -210,27 +212,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveBaseUrl(settings: EmbeddingSettingsDto): string {
|
function resolveBaseUrl(settings: EmbeddingSettingsDto): string {
|
||||||
const profile = settings.activeProfile?.providerKind === 'openai-compatible'
|
const profile =
|
||||||
? settings.activeProfile
|
settings.activeProfile?.providerKind === 'openai-compatible'
|
||||||
: getOpenAiProfile(settings);
|
? settings.activeProfile
|
||||||
|
: getOpenAiProfile(settings);
|
||||||
return typeof profile?.config.baseUrl === 'string'
|
return typeof profile?.config.baseUrl === 'string'
|
||||||
? profile.config.baseUrl
|
? profile.config.baseUrl
|
||||||
: 'https://api.openai.com/v1';
|
: 'https://api.openai.com/v1';
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveModel(settings: EmbeddingSettingsDto): string {
|
function resolveModel(settings: EmbeddingSettingsDto): string {
|
||||||
const profile = settings.activeProfile?.providerKind === 'openai-compatible'
|
const profile =
|
||||||
? settings.activeProfile
|
settings.activeProfile?.providerKind === 'openai-compatible'
|
||||||
: getOpenAiProfile(settings);
|
? settings.activeProfile
|
||||||
|
: getOpenAiProfile(settings);
|
||||||
return typeof profile?.config.model === 'string'
|
return typeof profile?.config.model === 'string'
|
||||||
? profile.config.model
|
? profile.config.model
|
||||||
: profile?.model ?? 'text-embedding-3-small';
|
: (profile?.model ?? 'text-embedding-3-small');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDimensions(settings: EmbeddingSettingsDto): number | undefined {
|
function resolveDimensions(settings: EmbeddingSettingsDto): number | undefined {
|
||||||
const profile = settings.activeProfile?.providerKind === 'openai-compatible'
|
const profile =
|
||||||
? settings.activeProfile
|
settings.activeProfile?.providerKind === 'openai-compatible'
|
||||||
: getOpenAiProfile(settings);
|
? settings.activeProfile
|
||||||
|
: getOpenAiProfile(settings);
|
||||||
return profile?.dimensions ?? 1536;
|
return profile?.dimensions ?? 1536;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,34 +301,38 @@
|
|||||||
<dt class="font-medium text-gray-500">Provider</dt>
|
<dt class="font-medium text-gray-500">Provider</dt>
|
||||||
<dd class="font-semibold text-gray-900">{activeProfile.providerKind}</dd>
|
<dd class="font-semibold text-gray-900">{activeProfile.providerKind}</dd>
|
||||||
<dt class="font-medium text-gray-500">Model</dt>
|
<dt class="font-medium text-gray-500">Model</dt>
|
||||||
<dd class="break-all font-semibold text-gray-900">{activeProfile.model}</dd>
|
<dd class="font-semibold break-all text-gray-900">{activeProfile.model}</dd>
|
||||||
<dt class="font-medium text-gray-500">Dimensions</dt>
|
<dt class="font-medium text-gray-500">Dimensions</dt>
|
||||||
<dd class="font-semibold text-gray-900">{activeProfile.dimensions}</dd>
|
<dd class="font-semibold text-gray-900">{activeProfile.dimensions}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-[110px_1fr] gap-x-4 gap-y-2 pt-3">
|
<div class="grid grid-cols-[110px_1fr] gap-x-4 gap-y-2 pt-3">
|
||||||
<dt class="text-gray-500">Enabled</dt>
|
<dt class="text-gray-500">Enabled</dt>
|
||||||
<dd class="font-medium text-gray-800">{activeProfile.enabled ? 'Yes' : 'No'}</dd>
|
<dd class="font-medium text-gray-800">{activeProfile.enabled ? 'Yes' : 'No'}</dd>
|
||||||
<dt class="text-gray-500">Default</dt>
|
<dt class="text-gray-500">Default</dt>
|
||||||
<dd class="font-medium text-gray-800">{activeProfile.isDefault ? 'Yes' : 'No'}</dd>
|
<dd class="font-medium text-gray-800">{activeProfile.isDefault ? 'Yes' : 'No'}</dd>
|
||||||
<dt class="text-gray-500">Updated</dt>
|
<dt class="text-gray-500">Updated</dt>
|
||||||
<dd class="font-medium text-gray-800">{formatTimestamp(activeProfile.updatedAt)}</dd>
|
<dd class="font-medium text-gray-800">{formatTimestamp(activeProfile.updatedAt)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
<p class="text-sm font-medium text-gray-800">Provider configuration</p>
|
<p class="text-sm font-medium text-gray-800">Provider configuration</p>
|
||||||
<p class="mb-3 mt-1 text-sm text-gray-500">
|
<p class="mt-1 mb-3 text-sm text-gray-500">
|
||||||
These are the provider-specific settings currently saved for the active profile.
|
These are the provider-specific settings currently saved for the active profile.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if activeConfigEntries.length > 0}
|
{#if activeConfigEntries.length > 0}
|
||||||
<ul class="space-y-2 text-sm">
|
<ul class="space-y-2 text-sm">
|
||||||
{#each activeConfigEntries as entry (entry.key)}
|
{#each activeConfigEntries as entry (entry.key)}
|
||||||
<li class="flex items-start justify-between gap-4 border-b border-gray-200 pb-2 last:border-b-0 last:pb-0">
|
<li
|
||||||
|
class="flex items-start justify-between gap-4 border-b border-gray-200 pb-2 last:border-b-0 last:pb-0"
|
||||||
|
>
|
||||||
<span class="font-medium text-gray-600">{entry.key}</span>
|
<span class="font-medium text-gray-600">{entry.key}</span>
|
||||||
<span class={entry.redacted ? 'text-gray-500' : 'text-gray-800'}>{entry.value}</span>
|
<span class={entry.redacted ? 'text-gray-500' : 'text-gray-800'}
|
||||||
|
>{entry.value}</span
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -332,9 +341,9 @@
|
|||||||
No provider-specific configuration is stored for this profile.
|
No provider-specific configuration is stored for this profile.
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-sm text-gray-500">
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
For <span class="font-medium text-gray-700">OpenAI-compatible</span> profiles, edit the
|
For <span class="font-medium text-gray-700">OpenAI-compatible</span> profiles, edit
|
||||||
settings in the <span class="font-medium text-gray-700">Embedding Provider</span> form
|
the settings in the <span class="font-medium text-gray-700">Embedding Provider</span>
|
||||||
below. The built-in <span class="font-medium text-gray-700">Local Model</span> profile
|
form below. The built-in <span class="font-medium text-gray-700">Local Model</span> profile
|
||||||
does not currently expose extra configurable fields.
|
does not currently expose extra configurable fields.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -342,14 +351,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||||
Embeddings are currently disabled. Keyword search remains available, but no embedding profile is active.
|
Embeddings are currently disabled. Keyword search remains available, but no embedding
|
||||||
|
profile is active.
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-6">
|
<div class="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
<h2 class="mb-1 text-base font-semibold text-gray-900">Profile Inventory</h2>
|
<h2 class="mb-1 text-base font-semibold text-gray-900">Profile Inventory</h2>
|
||||||
<p class="mb-4 text-sm text-gray-500">Profiles stored in the database and available for activation.</p>
|
<p class="mb-4 text-sm text-gray-500">
|
||||||
|
Profiles stored in the database and available for activation.
|
||||||
|
</p>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<StatBadge label="Profiles" value={String(currentSettings.profiles.length)} />
|
<StatBadge label="Profiles" value={String(currentSettings.profiles.length)} />
|
||||||
<StatBadge label="Active" value={activeProfile ? '1' : '0'} />
|
<StatBadge label="Active" value={activeProfile ? '1' : '0'} />
|
||||||
@@ -363,7 +375,9 @@
|
|||||||
<p class="text-gray-500">{profile.id}</p>
|
<p class="text-gray-500">{profile.id}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if profile.id === currentSettings.activeProfileId}
|
{#if profile.id === currentSettings.activeProfileId}
|
||||||
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">Active</span>
|
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700"
|
||||||
|
>Active</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,238 +393,234 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||||
<!-- Provider selector -->
|
<!-- Provider selector -->
|
||||||
<div class="mb-4 flex gap-2">
|
<div class="mb-4 flex gap-2">
|
||||||
{#each ['none', 'openai', 'local'] as p (p)}
|
{#each ['none', 'openai', 'local'] as p (p)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
provider = p as 'none' | 'openai' | 'local';
|
provider = p as 'none' | 'openai' | 'local';
|
||||||
testStatus = 'idle';
|
testStatus = 'idle';
|
||||||
testError = null;
|
testError = null;
|
||||||
}}
|
}}
|
||||||
class={[
|
class={[
|
||||||
'rounded-lg px-4 py-2 text-sm',
|
'rounded-lg px-4 py-2 text-sm',
|
||||||
provider === p
|
provider === p
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'border border-gray-200 text-gray-700 hover:bg-gray-50'
|
: 'border border-gray-200 text-gray-700 hover:bg-gray-50'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{p === 'none'
|
{p === 'none' ? 'None (FTS5 only)' : p === 'openai' ? 'OpenAI-compatible' : 'Local Model'}
|
||||||
? 'None (FTS5 only)'
|
</button>
|
||||||
: p === 'openai'
|
{/each}
|
||||||
? 'OpenAI-compatible'
|
</div>
|
||||||
: 'Local Model'}
|
|
||||||
</button>
|
<!-- None warning -->
|
||||||
{/each}
|
{#if provider === 'none'}
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
||||||
|
Search will use keyword matching only. Results may be less relevant for complex questions.
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- None warning -->
|
<!-- OpenAI-compatible form -->
|
||||||
{#if provider === 'none'}
|
{#if provider === 'openai'}
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
<div class="space-y-3">
|
||||||
Search will use keyword matching only. Results may be less relevant for complex questions.
|
<!-- Preset buttons -->
|
||||||
</div>
|
<div class="flex flex-wrap gap-2">
|
||||||
{/if}
|
{#each PROVIDER_PRESETS as preset (preset.name)}
|
||||||
|
|
||||||
<!-- OpenAI-compatible form -->
|
|
||||||
{#if provider === 'openai'}
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Preset buttons -->
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#each PROVIDER_PRESETS as preset (preset.name)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => applyPreset(preset)}
|
|
||||||
class="rounded border border-gray-200 px-2.5 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
{preset.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="block" for="embedding-base-url">
|
|
||||||
<span class="text-sm font-medium text-gray-700">Base URL</span>
|
|
||||||
<input
|
|
||||||
id="embedding-base-url"
|
|
||||||
name="baseUrl"
|
|
||||||
type="text"
|
|
||||||
autocomplete="url"
|
|
||||||
bind:value={baseUrl}
|
|
||||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="block" for="embedding-api-key">
|
|
||||||
<span class="text-sm font-medium text-gray-700">API Key</span>
|
|
||||||
<input
|
|
||||||
id="embedding-api-key"
|
|
||||||
name="apiKey"
|
|
||||||
type="password"
|
|
||||||
autocomplete="off"
|
|
||||||
bind:value={apiKey}
|
|
||||||
placeholder="sk-…"
|
|
||||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="block" for="embedding-model">
|
|
||||||
<span class="text-sm font-medium text-gray-700">Model</span>
|
|
||||||
<input
|
|
||||||
id="embedding-model"
|
|
||||||
name="model"
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
bind:value={model}
|
|
||||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="block" for="embedding-dimensions">
|
|
||||||
<span class="text-sm font-medium text-gray-700">Dimensions (optional override)</span>
|
|
||||||
<input
|
|
||||||
id="embedding-dimensions"
|
|
||||||
name="dimensions"
|
|
||||||
type="number"
|
|
||||||
inputmode="numeric"
|
|
||||||
bind:value={dimensions}
|
|
||||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Test connection row -->
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={testConnection}
|
onclick={() => applyPreset(preset)}
|
||||||
disabled={testStatus === 'testing'}
|
class="rounded border border-gray-200 px-2.5 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||||
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{testStatus === 'testing' ? 'Testing…' : 'Test Connection'}
|
{preset.name}
|
||||||
</button>
|
</button>
|
||||||
|
{/each}
|
||||||
{#if testStatus === 'ok'}
|
|
||||||
<span class="text-sm text-green-600">
|
|
||||||
Connection successful
|
|
||||||
{#if testDimensions}— {testDimensions} dimensions{/if}
|
|
||||||
</span>
|
|
||||||
{:else if testStatus === 'error'}
|
|
||||||
<span class="text-sm text-red-600">
|
|
||||||
{testError}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Local model section -->
|
|
||||||
{#if provider === 'local'}
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm">
|
|
||||||
<p class="font-medium text-gray-800">Local ONNX model via @xenova/transformers</p>
|
|
||||||
<p class="mt-1 text-gray-500">Model: Xenova/all-MiniLM-L6-v2 · 384 dimensions</p>
|
|
||||||
{#if getInitialLocalProviderAvailability()}
|
|
||||||
<p class="mt-2 text-green-600">@xenova/transformers is installed and ready.</p>
|
|
||||||
{:else}
|
|
||||||
<p class="mt-2 text-amber-700">
|
|
||||||
@xenova/transformers is not installed. Run
|
|
||||||
<code class="rounded bg-amber-100 px-1 py-0.5 font-mono text-xs"
|
|
||||||
>npm install @xenova/transformers</code
|
|
||||||
>
|
|
||||||
to enable local embeddings.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Indexing section -->
|
|
||||||
<div class="space-y-3 rounded-lg border border-gray-200 bg-white p-4">
|
|
||||||
<div>
|
|
||||||
<label for="concurrency" class="block text-sm font-medium text-gray-700">
|
|
||||||
Concurrent Workers
|
|
||||||
</label>
|
|
||||||
<p class="mt-0.5 text-xs text-gray-500">
|
|
||||||
Number of parallel indexing workers. Range: 1 to 8.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<label class="block" for="embedding-base-url">
|
||||||
|
<span class="text-sm font-medium text-gray-700">Base URL</span>
|
||||||
<input
|
<input
|
||||||
id="concurrency"
|
id="embedding-base-url"
|
||||||
type="number"
|
name="baseUrl"
|
||||||
min="1"
|
type="text"
|
||||||
max="8"
|
autocomplete="url"
|
||||||
inputmode="numeric"
|
bind:value={baseUrl}
|
||||||
bind:value={concurrencyInput}
|
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||||
disabled={concurrencySaving}
|
|
||||||
class="w-20 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none disabled:opacity-50"
|
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block" for="embedding-api-key">
|
||||||
|
<span class="text-sm font-medium text-gray-700">API Key</span>
|
||||||
|
<input
|
||||||
|
id="embedding-api-key"
|
||||||
|
name="apiKey"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
bind:value={apiKey}
|
||||||
|
placeholder="sk-…"
|
||||||
|
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block" for="embedding-model">
|
||||||
|
<span class="text-sm font-medium text-gray-700">Model</span>
|
||||||
|
<input
|
||||||
|
id="embedding-model"
|
||||||
|
name="model"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
bind:value={model}
|
||||||
|
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block" for="embedding-dimensions">
|
||||||
|
<span class="text-sm font-medium text-gray-700">Dimensions (optional override)</span>
|
||||||
|
<input
|
||||||
|
id="embedding-dimensions"
|
||||||
|
name="dimensions"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
bind:value={dimensions}
|
||||||
|
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Test connection row -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={saveConcurrency}
|
onclick={testConnection}
|
||||||
disabled={concurrencySaving}
|
disabled={testStatus === 'testing'}
|
||||||
class="rounded-lg bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{concurrencySaving ? 'Saving…' : 'Save'}
|
{testStatus === 'testing' ? 'Testing…' : 'Test Connection'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if concurrencySaveStatus === 'ok'}
|
{#if testStatus === 'ok'}
|
||||||
<span class="text-sm text-green-600">✓ Saved</span>
|
<span class="text-sm text-green-600">
|
||||||
{:else if concurrencySaveStatus === 'error'}
|
Connection successful
|
||||||
<span class="text-sm text-red-600">{concurrencySaveError}</span>
|
{#if testDimensions}— {testDimensions} dimensions{/if}
|
||||||
|
</span>
|
||||||
|
{:else if testStatus === 'error'}
|
||||||
|
<span class="text-sm text-red-600">
|
||||||
|
{testError}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Save feedback banners -->
|
<!-- Local model section -->
|
||||||
{#if saveStatus === 'ok'}
|
{#if provider === 'local'}
|
||||||
<div
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm">
|
||||||
class="mt-4 flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
|
<p class="font-medium text-gray-800">Local ONNX model via @xenova/transformers</p>
|
||||||
>
|
<p class="mt-1 text-gray-500">Model: Xenova/all-MiniLM-L6-v2 · 384 dimensions</p>
|
||||||
<svg
|
{#if getInitialLocalProviderAvailability()}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<p class="mt-2 text-green-600">@xenova/transformers is installed and ready.</p>
|
||||||
class="h-4 w-4 shrink-0"
|
{:else}
|
||||||
viewBox="0 0 20 20"
|
<p class="mt-2 text-amber-700">
|
||||||
fill="currentColor"
|
@xenova/transformers is not installed. Run
|
||||||
aria-hidden="true"
|
<code class="rounded bg-amber-100 px-1 py-0.5 font-mono text-xs"
|
||||||
>
|
>npm install @xenova/transformers</code
|
||||||
<path
|
>
|
||||||
fill-rule="evenodd"
|
to enable local embeddings.
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
</p>
|
||||||
clip-rule="evenodd"
|
{/if}
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Settings saved successfully.
|
|
||||||
</div>
|
|
||||||
{:else if saveStatus === 'error'}
|
|
||||||
<div
|
|
||||||
class="mt-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4 shrink-0"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{saveError}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Save row -->
|
|
||||||
<div class="mt-4 flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={saving}
|
|
||||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? 'Saving…' : 'Save Settings'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Indexing section -->
|
||||||
|
<div class="space-y-3 rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
<div>
|
||||||
|
<label for="concurrency" class="block text-sm font-medium text-gray-700">
|
||||||
|
Concurrent Workers
|
||||||
|
</label>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500">
|
||||||
|
Number of parallel indexing workers. Range: 1 to 8.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="concurrency"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="8"
|
||||||
|
inputmode="numeric"
|
||||||
|
bind:value={concurrencyInput}
|
||||||
|
disabled={concurrencySaving}
|
||||||
|
class="w-20 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={saveConcurrency}
|
||||||
|
disabled={concurrencySaving}
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{concurrencySaving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if concurrencySaveStatus === 'ok'}
|
||||||
|
<span class="text-sm text-green-600">✓ Saved</span>
|
||||||
|
{:else if concurrencySaveStatus === 'error'}
|
||||||
|
<span class="text-sm text-red-600">{concurrencySaveError}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save feedback banners -->
|
||||||
|
{#if saveStatus === 'ok'}
|
||||||
|
<div
|
||||||
|
class="mt-4 flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4 shrink-0"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Settings saved successfully.
|
||||||
|
</div>
|
||||||
|
{:else if saveStatus === 'error'}
|
||||||
|
<div
|
||||||
|
class="mt-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4 shrink-0"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Save row -->
|
||||||
|
<div class="mt-4 flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
class="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save Settings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user