// Command inventory-go is a Go reimplementation of the MosswartOverlord // inventory-service (FastAPI), deployed in parallel for comparison. // // Phase A: read side. Connects READ-ONLY to the existing inventory_db and // reimplements the read endpoints, validated against the Python service on the // same data. The heavy item-processing ingestion and the suitbuilder solver // follow in later phases. package main import ( "context" "encoding/json" "errors" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/jackc/pgx/v5/pgxpool" ) var buildVersion = "dev" type Server struct { pool *pgxpool.Pool attributeSets map[string]string // AttributeSetInfo: set-id -> set name log *slog.Logger } func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) addr := ":" + envOr("PORT", "8772") dsn := os.Getenv("DATABASE_URL") enumPath := envOr("ENUM_DB_PATH", "comprehensive_enum_database_v2.json") readOnly := envOr("READ_ONLY", "true") != "false" logger.Info("starting inventory-go", "version", buildVersion, "addr", addr, "read_only", readOnly) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() srv := &Server{log: logger, attributeSets: map[string]string{}} if sets, err := loadAttributeSets(enumPath); err != nil { logger.Warn("could not load enum AttributeSetInfo (set names will be unknown)", "err", err, "path", enumPath) } else { srv.attributeSets = sets logger.Info("loaded enum AttributeSetInfo", "sets", len(sets)) } if dsn == "" { logger.Error("DATABASE_URL is required") os.Exit(1) } connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second) pool, err := newPool(connectCtx, dsn, readOnly) cancel() if err != nil { logger.Error("db pool init failed", "err", err) os.Exit(1) } defer pool.Close() srv.pool = pool mux := http.NewServeMux() mux.HandleFunc("GET /health", srv.handleHealth) mux.HandleFunc("GET /sets/list", srv.handleSetsList) mux.HandleFunc("GET /characters/list", srv.handleCharactersList) httpSrv := &http.Server{Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 10 * time.Second} go func() { if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("http server failed", "err", err) os.Exit(1) } }() logger.Info("listening", "addr", addr) <-ctx.Done() shutdownCtx, c := context.WithTimeout(context.Background(), 10*time.Second) defer c() _ = httpSrv.Shutdown(shutdownCtx) logger.Info("stopped") } // GET /health (main.py:2674) func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() dbOK := s.pool.Ping(ctx) == nil status := "healthy" if !dbOK { status = "degraded" } writeJSON(w, http.StatusOK, map[string]any{ "status": status, "timestamp": pyISO(time.Now()), "database_connected": dbOK, "version": "1.0.0", }) } // GET /sets/list (main.py:2712) func (s *Server) handleSetsList(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() rows, err := queryRowsAsMaps(ctx, s.pool, ` SELECT enh.item_set, COUNT(*) AS item_count FROM item_enhancements enh WHERE enh.item_set IS NOT NULL AND enh.item_set != '' GROUP BY enh.item_set ORDER BY item_count DESC, enh.item_set`) if err != nil { s.dbErr(w, "sets/list", err) return } sets := make([]map[string]any, 0, len(rows)) for _, row := range rows { setID := toStr(row["item_set"]) name, ok := s.attributeSets[setID] if !ok { name = "Unknown Set " + setID } sets = append(sets, map[string]any{"id": setID, "name": name, "item_count": row["item_count"]}) } writeJSON(w, http.StatusOK, map[string]any{"sets": sets}) } // GET /characters/list (main.py:4291) func (s *Server) handleCharactersList(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() rows, err := queryRowsAsMaps(ctx, s.pool, ` SELECT character_name, COUNT(*) AS item_count, MAX(timestamp) AS last_updated FROM items GROUP BY character_name ORDER BY character_name`) if err != nil { s.dbErr(w, "characters/list", err) return } formatTimes(rows, "last_updated") chars := make([]map[string]any, 0, len(rows)) for _, row := range rows { chars = append(chars, map[string]any{ "character_name": row["character_name"], "item_count": row["item_count"], "last_updated": row["last_updated"], }) } writeJSON(w, http.StatusOK, map[string]any{"characters": chars, "total_characters": len(chars)}) } func (s *Server) dbErr(w http.ResponseWriter, where string, err error) { s.log.Error("db query failed", "where", where, "err", err) writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Internal server error"}) } // loadAttributeSets reads the comprehensive enum DB and extracts AttributeSetInfo // (set-id -> name), mirroring load_comprehensive_enums (dictionaries first, then // enums). Only the slice needed for /sets/list is decoded. func loadAttributeSets(path string) (map[string]string, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } var db struct { Dictionaries map[string]struct { Values map[string]string `json:"values"` } `json:"dictionaries"` Enums map[string]struct { Values map[string]string `json:"values"` } `json:"enums"` } if err := json.Unmarshal(b, &db); err != nil { return nil, err } if d, ok := db.Dictionaries["AttributeSetInfo"]; ok && len(d.Values) > 0 { return d.Values, nil } if e, ok := db.Enums["AttributeSetInfo"]; ok { return e.Values, nil } return map[string]string{}, nil } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func withLogging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) slog.Info("http", "method", r.Method, "path", r.URL.Path, "dur_ms", time.Since(start).Milliseconds()) }) }