package sqlite import ( "context" "database/sql" "encoding/json" "fmt" "math" "sort" "strings" "time" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) type WorkspaceStore struct{} func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} } // ── columns ──────────────────────────────── const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, indexing_enabled, git_remote_url, git_branch, git_credential_id, git_last_sync, created_at, updated_at` const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, index_status, chunk_count, created_at, updated_at` // ── scanners ─────────────────────────────── func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) { var w models.Workspace var maxBytes sql.NullInt64 var gitRemote, gitBranch, gitCredID sql.NullString err := row.Scan( &w.ID, &w.OwnerType, &w.OwnerID, &w.Name, &w.RootPath, &maxBytes, &w.Status, &w.IndexingEnabled, &gitRemote, &gitBranch, &gitCredID, stN(&w.GitLastSync), st(&w.CreatedAt), st(&w.UpdatedAt), ) if err != nil { return nil, err } if maxBytes.Valid { w.MaxBytes = &maxBytes.Int64 } if gitRemote.Valid { w.GitRemoteURL = &gitRemote.String } if gitBranch.Valid { w.GitBranch = &gitBranch.String } if gitCredID.Valid { w.GitCredentialID = &gitCredID.String } return &w, nil } func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) { var f models.WorkspaceFile var contentType sql.NullString var sha256 sql.NullString var indexStatus sql.NullString err := row.Scan( &f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory, &contentType, &f.SizeBytes, &sha256, &indexStatus, &f.ChunkCount, st(&f.CreatedAt), st(&f.UpdatedAt), ) if err != nil { return nil, err } if contentType.Valid { f.ContentType = contentType.String } if sha256.Valid { f.SHA256 = sha256.String } if indexStatus.Valid { f.IndexStatus = indexStatus.String } return &f, nil } // ── Workspace CRUD ───────────────────────── func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { if w.ID == "" { w.ID = store.NewID() } now := time.Now().UTC() w.CreatedAt = now w.UpdatedAt = now _, err := DB.ExecContext(ctx, ` INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath, w.MaxBytes, w.Status, now.Format(timeFmt), now.Format(timeFmt), ) return err } func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE id = ?`, id) return scanWorkspace(row) } func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error { b := NewUpdate("workspaces") if patch.Name != nil { b.Set("name", *patch.Name) } if patch.MaxBytes != nil { b.Set("max_bytes", *patch.MaxBytes) } if patch.Status != nil { b.Set("status", *patch.Status) } if patch.IndexingEnabled != nil { b.Set("indexing_enabled", *patch.IndexingEnabled) } if patch.GitRemoteURL != nil { b.Set("git_remote_url", *patch.GitRemoteURL) } if patch.GitBranch != nil { b.Set("git_branch", *patch.GitBranch) } if patch.GitCredentialID != nil { b.Set("git_credential_id", *patch.GitCredentialID) } if !b.HasSets() { return nil } b.SetExpr("updated_at", nowExpr) b.Where("id", id) _, err := b.Exec(DB) return err } func (s *WorkspaceStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id) return err } // ── Ownership lookup ─────────────────────── func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE owner_type = ? AND owner_id = ? AND status != 'deleting' ORDER BY created_at DESC LIMIT 1`, ownerType, ownerID) return scanWorkspace(row) } func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) { rows, err := DB.QueryContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE owner_type = ? AND owner_id = ? AND status != 'deleting' ORDER BY created_at DESC`, ownerType, ownerID) if err != nil { return nil, err } defer rows.Close() var out []models.Workspace for rows.Next() { w, err := scanWorkspace(rows) if err != nil { return nil, err } out = append(out, *w) } return out, rows.Err() } // ── File index ───────────────────────────── func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error { if f.ID == "" { f.ID = store.NewID() } now := time.Now().UTC() f.CreatedAt = now f.UpdatedAt = now _, err := DB.ExecContext(ctx, ` INSERT INTO workspace_files (id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (workspace_id, path) DO UPDATE SET is_directory = excluded.is_directory, content_type = excluded.content_type, size_bytes = excluded.size_bytes, sha256 = excluded.sha256, updated_at = datetime('now')`, f.ID, f.WorkspaceID, f.Path, f.IsDirectory, models.NullString(&f.ContentType), f.SizeBytes, models.NullString(&f.SHA256), now.Format(timeFmt), now.Format(timeFmt), ) return err } func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = ? AND path = ?`, workspaceID, path) return err } func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = ? AND path LIKE ?`, workspaceID, prefix+"%") return err } func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceFileCols+` FROM workspace_files WHERE workspace_id = ? AND path = ?`, workspaceID, path) return scanWorkspaceFile(row) } func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) { var query string var args []interface{} if recursive { if prefix == "" { query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = ? ORDER BY path` args = []interface{}{workspaceID} } else { query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = ? AND path LIKE ? ORDER BY path` args = []interface{}{workspaceID, prefix + "%"} } } else { if prefix == "" { query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = ? AND path NOT LIKE '%/%' ORDER BY path` args = []interface{}{workspaceID} } else { // Direct children: starts with prefix, no slash after prefix query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files WHERE workspace_id = ? AND path LIKE ? AND substr(path, %d) NOT LIKE '%%/%%' ORDER BY path`, len(prefix)+1) args = []interface{}{workspaceID, prefix + "%"} } } rows, err := DB.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var out []models.WorkspaceFile for rows.Next() { f, err := scanWorkspaceFile(rows) if err != nil { return nil, err } out = append(out, *f) } return out, rows.Err() } func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = ?`, workspaceID) return err } // ── Stats ────────────────────────────────── func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) { stats := &models.WorkspaceStats{WorkspaceID: workspaceID} err := DB.QueryRowContext(ctx, ` SELECT COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0), COALESCE(SUM(size_bytes), 0) FROM workspace_files WHERE workspace_id = ?`, workspaceID, ).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes) if err != nil { return nil, err } var maxBytes sql.NullInt64 err = DB.QueryRowContext(ctx, `SELECT max_bytes FROM workspaces WHERE id = ?`, workspaceID, ).Scan(&maxBytes) if err != nil && err != sql.ErrNoRows { return nil, err } if maxBytes.Valid { stats.MaxBytes = &maxBytes.Int64 } return stats, nil } // ── Chunks (v0.21.2) ───────────────────────── func (s *WorkspaceStore) InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error { if len(chunks) == 0 { return nil } valueParts := make([]string, 0, len(chunks)) args := make([]interface{}, 0, len(chunks)*8) for _, c := range chunks { c.ID = store.NewID() valueParts = append(valueParts, "(?, ?, ?, ?, ?, ?, ?, ?)") var embJSON interface{} if len(c.Embedding) > 0 { embJSON = ToJSON(c.Embedding) } args = append(args, c.ID, c.WorkspaceID, c.FileID, c.ChunkIndex, c.Content, c.TokenCount, embJSON, ToJSON(c.Metadata)) } q := fmt.Sprintf(`INSERT INTO workspace_chunks (id, workspace_id, file_id, chunk_index, content, token_count, embedding, metadata) VALUES %s`, strings.Join(valueParts, ", ")) _, err := DB.ExecContext(ctx, q, args...) return err } func (s *WorkspaceStore) DeleteChunksByFile(ctx context.Context, fileID string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_chunks WHERE file_id = ?`, fileID) return err } // SimilaritySearch performs app-level cosine similarity for SQLite. // Loads all chunks for the workspace, computes cosine distance in Go, // and returns the top results above threshold. func (s *WorkspaceStore) SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error) { if len(queryVec) == 0 { return nil, nil } if limit <= 0 { limit = 10 } rows, err := DB.QueryContext(ctx, ` SELECT f.path, c.content, c.embedding, c.metadata FROM workspace_chunks c JOIN workspace_files f ON c.file_id = f.id WHERE c.workspace_id = ? AND c.embedding IS NOT NULL`, workspaceID) if err != nil { return nil, err } defer rows.Close() type candidate struct { result models.WorkspaceChunkResult similarity float64 } var candidates []candidate for rows.Next() { var filePath, content, embJSON string var metaJSON sql.NullString if err := rows.Scan(&filePath, &content, &embJSON, &metaJSON); err != nil { return nil, err } var embedding []float64 if err := json.Unmarshal([]byte(embJSON), &embedding); err != nil { continue // skip malformed } sim := wsCosineSimilarity(queryVec, embedding) if sim > threshold { r := models.WorkspaceChunkResult{ FilePath: filePath, Content: content, Score: sim, } if metaJSON.Valid { ScanJSON(metaJSON.String, &r.Metadata) } if r.Metadata != nil { if lh, ok := r.Metadata["line_start"]; ok { if n, ok := lh.(float64); ok { r.LineHint = int(n) } } } candidates = append(candidates, candidate{result: r, similarity: sim}) } } if err := rows.Err(); err != nil { return nil, err } sort.Slice(candidates, func(i, j int) bool { return candidates[i].similarity > candidates[j].similarity }) if len(candidates) > limit { candidates = candidates[:limit] } results := make([]models.WorkspaceChunkResult, len(candidates)) for i, c := range candidates { results[i] = c.result } return results, nil } func (s *WorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error { _, err := DB.ExecContext(ctx, `UPDATE workspace_files SET index_status = ?, chunk_count = ?, updated_at = datetime('now') WHERE id = ?`, status, chunkCount, fileID) return err } func (s *WorkspaceStore) SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error { _, err := DB.ExecContext(ctx, `UPDATE workspaces SET git_last_sync = ?, updated_at = datetime('now') WHERE id = ?`, t.Format(timeFmt), workspaceID) return err } // wsCosineSimilarity computes cosine similarity between two vectors. // Named to avoid collision with the KB package-level function. func wsCosineSimilarity(a, b []float64) float64 { if len(a) != len(b) || len(a) == 0 { return 0 } var dot, normA, normB float64 for i := range a { dot += a[i] * b[i] normA += a[i] * a[i] normB += b[i] * b[i] } if normA == 0 || normB == 0 { return 0 } return dot / (math.Sqrt(normA) * math.Sqrt(normB)) }