package main import ( "context" "database/sql" "encoding/json" "fmt" "net/http" "strings" "sync" "time" "fn-registry/apps/data_factory/datafactory" "fn-registry/functions/infra" ) const queryTimeout = 5 * time.Second // Server holds the HTTP handlers and DB pool. type Server struct { pool *DBPool registryRoot string // raiz del fn_registry (para exec fn ...) hub *CallMonitorHub dfHub *DataFactoryHub // data_factory.db (RW) is opened lazily on first request and reused. dfPath string dfMigrationsDir string dfMu sync.Mutex dfDB *sql.DB dfErr error // sticky if a previous open failed } func NewServer(pool *DBPool, registryRoot, dfPath, dfMigrationsDir string) *Server { s := &Server{ pool: pool, registryRoot: registryRoot, hub: NewCallMonitorHub(pool), dfPath: dfPath, dfMigrationsDir: dfMigrationsDir, } s.dfHub = NewDataFactoryHub(s.dataFactoryDB) return s } // dataFactoryDB returns a RW connection to data_factory.db, lazy-opened on // first call. Subsequent calls return the cached connection. If the open // fails it is cached as a sticky error to avoid retry storms; retry after // process restart. func (s *Server) dataFactoryDB() (*sql.DB, error) { s.dfMu.Lock() defer s.dfMu.Unlock() if s.dfDB != nil { return s.dfDB, nil } if s.dfErr != nil { return nil, s.dfErr } db, err := datafactory.Open(s.dfPath, s.dfMigrationsDir) if err != nil { s.dfErr = err return nil, err } s.dfDB = db return db, nil } // Routes registers all API routes on the given mux. func (s *Server) Routes(mux *http.ServeMux) { mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /api/databases", s.handleDatabases) mux.HandleFunc("GET /api/databases/{db}/tables", s.handleTables) mux.HandleFunc("GET /api/databases/{db}/schema", s.handleSchema) mux.HandleFunc("POST /api/databases/{db}/query", s.handleQuery) mux.HandleFunc("GET /api/databases/{db}/fts", s.handleFTS) // Projects: listado con conteos + detalle nested mux.HandleFunc("GET /api/projects", s.handleProjects) mux.HandleFunc("GET /api/projects/{id}", s.handleProjectDetail) // Mutaciones: reindex + add (apps/analysis/vaults) mux.HandleFunc("POST /api/reindex", s.handleReindex) mux.HandleFunc("POST /api/add/app", s.handleAddApp) mux.HandleFunc("POST /api/add/analysis", s.handleAddAnalysis) mux.HandleFunc("POST /api/add/vault", s.handleAddVault) // Issue 0086: WebSocket live stream de ops:call_monitor (calls table). // Hub global con ticker bajo demanda (solo corre con >=1 subscriber). mux.HandleFunc("GET /api/events/call_monitor", s.handleEvents(s.hub)) // Issue 0097: data_factory.db (nodes, runs, databases). // REST read-only + WS live stream sobre `runs`. mux.HandleFunc("GET /api/datafactory/nodes", s.handleDataFactoryNodes) mux.HandleFunc("GET /api/datafactory/runs", s.handleDataFactoryRuns) mux.HandleFunc("GET /api/datafactory/databases", s.handleDataFactoryDatabases) mux.HandleFunc("GET /api/ws/datafactory", s.handleDataFactoryEvents(s.dfHub)) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleDatabases(w http.ResponseWriter, r *http.Request) { entries := s.pool.List() type dbInfo struct { Alias string `json:"alias"` Kind string `json:"kind"` } out := make([]dbInfo, 0, len(entries)+1) for _, e := range entries { out = append(out, dbInfo{Alias: e.Alias, Kind: e.Kind}) } // Surface data_factory.db too. It is opened RW outside the pool but // belongs in the discovery list so dashboards can see it. if s.dfPath != "" { out = append(out, dbInfo{Alias: "data_factory", Kind: "data_factory"}) } writeJSON(w, http.StatusOK, out) } func (s *Server) handleTables(w http.ResponseWriter, r *http.Request) { dbAlias := resolveDBAlias(r.PathValue("db")) db, err := s.pool.Get(dbAlias) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } ctx, cancel := context.WithTimeout(r.Context(), queryTimeout) defer cancel() rows, err := db.QueryContext(ctx, "SELECT type, name FROM sqlite_master WHERE type IN ('table','view') ORDER BY type, name") if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } defer rows.Close() type tableInfo struct { Type string `json:"type"` Name string `json:"name"` } var tables []tableInfo for rows.Next() { var t tableInfo if err := rows.Scan(&t.Type, &t.Name); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } tables = append(tables, t) } if tables == nil { tables = []tableInfo{} } writeJSON(w, http.StatusOK, tables) } func (s *Server) handleSchema(w http.ResponseWriter, r *http.Request) { dbAlias := resolveDBAlias(r.PathValue("db")) db, err := s.pool.Get(dbAlias) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } ctx, cancel := context.WithTimeout(r.Context(), queryTimeout) defer cancel() rows, err := db.QueryContext(ctx, "SELECT sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name") if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } defer rows.Close() var statements []string for rows.Next() { var stmt string if err := rows.Scan(&stmt); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } statements = append(statements, stmt) } writeJSON(w, http.StatusOK, map[string]any{ "statements": statements, "count": len(statements), }) } func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) { dbAlias := resolveDBAlias(r.PathValue("db")) db, err := s.pool.Get(dbAlias) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } var req struct { SQL string `json:"sql"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } if req.SQL == "" { writeError(w, http.StatusBadRequest, "sql field is required") return } if err := ValidateQuery(req.SQL); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } ctx, cancel := context.WithTimeout(r.Context(), queryTimeout) defer cancel() start := time.Now() rows, err := db.QueryContext(ctx, req.SQL) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } defer rows.Close() columns, err := rows.Columns() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } var resultRows [][]any for rows.Next() { vals := make([]any, len(columns)) ptrs := make([]any, len(columns)) for i := range vals { ptrs[i] = &vals[i] } if err := rows.Scan(ptrs...); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } // Convert []byte to string for JSON serialization. for i, v := range vals { if b, ok := v.([]byte); ok { vals[i] = string(b) } } resultRows = append(resultRows, vals) } if resultRows == nil { resultRows = [][]any{} } durationMs := time.Since(start).Milliseconds() writeJSON(w, http.StatusOK, map[string]any{ "columns": columns, "rows": resultRows, "count": len(resultRows), "duration_ms": durationMs, }) } func (s *Server) handleFTS(w http.ResponseWriter, r *http.Request) { dbAlias := resolveDBAlias(r.PathValue("db")) db, err := s.pool.Get(dbAlias) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } q := r.URL.Query().Get("q") if q == "" { writeError(w, http.StatusBadRequest, "q parameter is required") return } table := r.URL.Query().Get("table") if table == "" { table = "functions" } // Map table to its FTS table and columns to return. ftsTable := table + "_fts" var selectCols string switch table { case "functions": selectCols = "id, name, kind, purity, domain, description" case "types": selectCols = "id, name, algebraic, domain, description" case "unit_tests": selectCols = "id, name, function_id, lang" default: writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported FTS table: %s", table)) return } limit := r.URL.Query().Get("limit") if limit == "" { limit = "20" } query := fmt.Sprintf( "SELECT %s FROM %s WHERE id IN (SELECT id FROM %s WHERE %s MATCH ?) LIMIT %s", selectCols, table, ftsTable, ftsTable, limit, ) ctx, cancel := context.WithTimeout(r.Context(), queryTimeout) defer cancel() start := time.Now() rows, err := db.QueryContext(ctx, query, q) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } defer rows.Close() columns, err := rows.Columns() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } var resultRows [][]any for rows.Next() { vals := make([]any, len(columns)) ptrs := make([]any, len(columns)) for i := range vals { ptrs[i] = &vals[i] } if err := rows.Scan(ptrs...); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } for i, v := range vals { if b, ok := v.([]byte); ok { vals[i] = string(b) } } resultRows = append(resultRows, vals) } if resultRows == nil { resultRows = [][]any{} } durationMs := time.Since(start).Milliseconds() writeJSON(w, http.StatusOK, map[string]any{ "columns": columns, "rows": resultRows, "count": len(resultRows), "duration_ms": durationMs, }) } // resolveDBAlias converts URL path segment to pool alias. // "registry" → "registry", "ops:app_name" → "ops:app_name" // URL-encoded colons: "ops%3Aapp_name" → already decoded by net/http. func resolveDBAlias(raw string) string { return raw } // CORS middleware for browser access. func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } func writeJSON(w http.ResponseWriter, status int, data any) { infra.HTTPJSONResponse(w, status, data) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // sanitizeFTS escapes special FTS5 characters in user input. func sanitizeFTS(input string) string { // Wrap each term in double quotes to treat as literal. terms := strings.Fields(input) for i, t := range terms { terms[i] = `"` + strings.ReplaceAll(t, `"`, `""`) + `"` } return strings.Join(terms, " ") }