package main import ( "context" "embed" "encoding/json" "errors" "fmt" "html/template" "io/fs" "log" "net/http" "os" "os/signal" "strconv" "strings" "sync" "syscall" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/nats-io/nats.go" ) //go:embed internal/web/templates/index.html var templateFS embed.FS //go:embed internal/web/static var staticFS embed.FS type Config struct { HTTPAddr string DatabaseURL string NATSURL string NATSSubject string } const maxInventoryItems = 4 type InventoryItem struct { ID int `json:"id"` SKU string `json:"sku"` Name string `json:"name"` Quantity int `json:"quantity"` Location string `json:"location"` UpdatedAt time.Time `json:"updated_at"` } type AcceptedResponse struct { Status string `json:"status"` CRUD string `json:"crud"` Command string `json:"command"` } type CDCEvent struct { CRUD string `json:"crud"` Op string `json:"op"` Table string `json:"table"` Before any `json:"before"` After any `json:"after"` Raw json.RawMessage `json:"raw"` Inventory []InventoryItem `json:"inventory"` Received time.Time `json:"received_at"` Source string `json:"source"` } type Server struct { db *pgxpool.Pool broker *Broker templates *template.Template } type Broker struct { mu sync.Mutex clients map[chan CDCEvent]struct{} last *CDCEvent } func main() { cfg := loadConfig() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() db, err := connectDB(ctx, cfg.DatabaseURL) if err != nil { log.Fatalf("connect postgres: %v", err) } defer db.Close() tpl, err := template.ParseFS(templateFS, "internal/web/templates/index.html") if err != nil { log.Fatalf("parse templates: %v", err) } server := &Server{ db: db, broker: NewBroker(), templates: tpl, } go consumeNATS(ctx, cfg, db, server.broker) mux := http.NewServeMux() server.routes(mux) httpServer := &http.Server{ Addr: cfg.HTTPAddr, Handler: logRequests(mux), ReadHeaderTimeout: 5 * time.Second, } go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = httpServer.Shutdown(shutdownCtx) }() log.Printf("starting live inventory CDC demo on %s", cfg.HTTPAddr) if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("http server: %v", err) } } func loadConfig() Config { return Config{ HTTPAddr: env("HTTP_ADDR", ":8080"), DatabaseURL: env("DATABASE_URL", "postgres://postgres:postgres@localhost:5453/postgres?sslmode=disable"), NATSURL: env("NATS_URL", "nats://localhost:4222"), NATSSubject: env("NATS_SUBJECT", "postgres.public.inventory"), } } func env(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func connectDB(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { var lastErr error for attempt := 1; attempt <= 30; attempt++ { db, err := pgxpool.New(ctx, databaseURL) if err == nil { pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second) err = db.Ping(pingCtx) cancel() if err == nil { return db, nil } db.Close() } lastErr = err select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(time.Duration(attempt) * 250 * time.Millisecond): } } return nil, lastErr } func (s *Server) routes(mux *http.ServeMux) { static, err := fs.Sub(staticFS, "internal/web/static") if err != nil { panic(err) } mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static))) mux.HandleFunc("GET /", s.handleIndex) mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /events", s.handleEvents) mux.HandleFunc("GET /api/inventory", s.handleListInventory) mux.HandleFunc("POST /api/inventory", s.handleCreateInventory) mux.HandleFunc("PATCH /api/inventory/{id}", s.handleUpdateInventory) mux.HandleFunc("PATCH /api/inventory/{id}/sell", s.handleSellInventory) mux.HandleFunc("PATCH /api/inventory/{id}/restock", s.handleRestockInventory) mux.HandleFunc("DELETE /api/inventory/{id}", s.handleDeleteInventory) } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") data := map[string]string{"Version": "dev"} if err := s.templates.ExecuteTemplate(w, "index.html", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } client := s.broker.Subscribe() defer s.broker.Unsubscribe(client) if last := s.broker.Last(); last != nil { writeSSE(w, *last) flusher.Flush() } ticker := time.NewTicker(20 * time.Second) defer ticker.Stop() for { select { case <-r.Context().Done(): return case event := <-client: writeSSE(w, event) flusher.Flush() case <-ticker.C: _, _ = fmt.Fprint(w, ": keepalive\n\n") flusher.Flush() } } } func (s *Server) handleListInventory(w http.ResponseWriter, r *http.Request) { items, err := listInventory(r.Context(), s.db) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, items) } func (s *Server) handleCreateInventory(w http.ResponseWriter, r *http.Request) { var req struct { SKU string `json:"sku"` Name string `json:"name"` Quantity int `json:"quantity"` Location string `json:"location"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, err) return } req.SKU = strings.TrimSpace(req.SKU) req.Name = strings.TrimSpace(req.Name) req.Location = strings.TrimSpace(req.Location) if req.SKU == "" || req.Name == "" || req.Location == "" { writeError(w, http.StatusBadRequest, errors.New("sku, name, and location are required")) return } tx, err := s.db.BeginTx(r.Context(), pgx.TxOptions{IsoLevel: pgx.Serializable}) if err != nil { writeError(w, http.StatusInternalServerError, err) return } defer tx.Rollback(r.Context()) if _, err := tx.Exec(r.Context(), `lock table inventory in exclusive mode`); err != nil { writeError(w, http.StatusInternalServerError, err) return } var count int if err := tx.QueryRow(r.Context(), `select count(*) from inventory`).Scan(&count); err != nil { writeError(w, http.StatusInternalServerError, err) return } if count >= maxInventoryItems { writeError(w, http.StatusConflict, fmt.Errorf("inventory is limited to %d products", maxInventoryItems)) return } _, err = tx.Exec(r.Context(), ` insert into inventory (sku, name, quantity, location, updated_at) values ($1, $2, $3, $4, now())`, req.SKU, req.Name, req.Quantity, req.Location, ) if err != nil { writeError(w, http.StatusBadRequest, err) return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusAccepted, AcceptedResponse{ Status: "command accepted", CRUD: "CREATE", Command: fmt.Sprintf("CREATE inventory %s", req.SKU), }) } func (s *Server) handleSellInventory(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } _, err := s.db.Exec(r.Context(), ` update inventory set quantity = greatest(quantity - 1, 0), updated_at = now() where id = $1`, id, ) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusAccepted, AcceptedResponse{ Status: "command accepted", CRUD: "UPDATE", Command: "UPDATE inventory quantity -1", }) } func (s *Server) handleRestockInventory(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } var req struct { Amount int `json:"amount"` } _ = json.NewDecoder(r.Body).Decode(&req) if req.Amount <= 0 { req.Amount = 5 } _, err := s.db.Exec(r.Context(), ` update inventory set quantity = quantity + $2, updated_at = now() where id = $1`, id, req.Amount, ) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusAccepted, AcceptedResponse{ Status: "command accepted", CRUD: "UPDATE", Command: fmt.Sprintf("UPDATE inventory quantity +%d", req.Amount), }) } func (s *Server) handleUpdateInventory(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } var req struct { Name *string `json:"name"` Location *string `json:"location"` Quantity *int `json:"quantity"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, err) return } tx, err := s.db.BeginTx(r.Context(), pgx.TxOptions{}) if err != nil { writeError(w, http.StatusInternalServerError, err) return } defer tx.Rollback(r.Context()) current := InventoryItem{} err = tx.QueryRow(r.Context(), ` select id, sku, name, quantity, location, updated_at from inventory where id = $1 for update`, id, ).Scan(¤t.ID, ¤t.SKU, ¤t.Name, ¤t.Quantity, ¤t.Location, ¤t.UpdatedAt) if err != nil { writeError(w, http.StatusNotFound, err) return } if req.Name != nil && strings.TrimSpace(*req.Name) != "" { current.Name = strings.TrimSpace(*req.Name) } if req.Location != nil && strings.TrimSpace(*req.Location) != "" { current.Location = strings.TrimSpace(*req.Location) } if req.Quantity != nil { current.Quantity = *req.Quantity } _, err = tx.Exec(r.Context(), ` update inventory set name = $2, quantity = $3, location = $4, updated_at = now() where id = $1`, current.ID, current.Name, current.Quantity, current.Location, ) if err != nil { writeError(w, http.StatusInternalServerError, err) return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusAccepted, AcceptedResponse{ Status: "command accepted", CRUD: "UPDATE", Command: fmt.Sprintf("UPDATE inventory %s", current.SKU), }) } func (s *Server) handleDeleteInventory(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } _, err := s.db.Exec(r.Context(), `delete from inventory where id = $1`, id) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusAccepted, AcceptedResponse{ Status: "command accepted", CRUD: "DELETE", Command: fmt.Sprintf("DELETE inventory id=%d", id), }) } func pathID(w http.ResponseWriter, r *http.Request) (int, bool) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil || id <= 0 { writeError(w, http.StatusBadRequest, errors.New("valid numeric id is required")) return 0, false } return id, true } func consumeNATS(ctx context.Context, cfg Config, db *pgxpool.Pool, broker *Broker) { for { select { case <-ctx.Done(): return default: } nc, err := nats.Connect( cfg.NATSURL, nats.Name("live-inventory-cdc-demo"), nats.RetryOnFailedConnect(true), nats.MaxReconnects(-1), nats.ReconnectWait(2*time.Second), ) if err != nil { log.Printf("connect nats: %v", err) sleepOrDone(ctx, 2*time.Second) continue } sub, err := nc.Subscribe(cfg.NATSSubject, func(msg *nats.Msg) { event, err := normalizeCDC(ctx, db, msg.Data) if err != nil { log.Printf("normalize cdc event: %v", err) return } broker.Publish(event) }) if err != nil { log.Printf("subscribe nats: %v", err) nc.Close() sleepOrDone(ctx, 2*time.Second) continue } log.Printf("subscribed to nats subject %s", cfg.NATSSubject) <-ctx.Done() _ = sub.Unsubscribe() nc.Close() return } } func normalizeCDC(ctx context.Context, db *pgxpool.Pool, payload []byte) (CDCEvent, error) { var raw map[string]any if err := json.Unmarshal(payload, &raw); err != nil { return CDCEvent{}, err } op, _ := raw["op"].(string) table := "" if source, ok := raw["source"].(map[string]any); ok { table, _ = source["table"].(string) } if table == "" { table = "inventory" } items, err := listInventory(ctx, db) if err != nil { return CDCEvent{}, err } return CDCEvent{ CRUD: crudLabel(op), Op: op, Table: table, Before: raw["before"], After: raw["after"], Raw: append(json.RawMessage(nil), payload...), Inventory: items, Received: time.Now().UTC(), Source: "Debezium -> NATS JetStream -> SSE", }, nil } func crudLabel(op string) string { switch op { case "c": return "CREATE" case "r": return "SNAPSHOT" case "u": return "UPDATE" case "d": return "DELETE" default: return strings.ToUpper(op) } } func listInventory(ctx context.Context, db *pgxpool.Pool) ([]InventoryItem, error) { rows, err := db.Query(ctx, ` select id, sku, name, quantity, location, updated_at from inventory order by id`) if err != nil { return nil, err } defer rows.Close() items := make([]InventoryItem, 0) for rows.Next() { var item InventoryItem if err := rows.Scan(&item.ID, &item.SKU, &item.Name, &item.Quantity, &item.Location, &item.UpdatedAt); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func NewBroker() *Broker { return &Broker{clients: make(map[chan CDCEvent]struct{})} } func (b *Broker) Subscribe() chan CDCEvent { ch := make(chan CDCEvent, 8) b.mu.Lock() b.clients[ch] = struct{}{} b.mu.Unlock() return ch } func (b *Broker) Unsubscribe(ch chan CDCEvent) { b.mu.Lock() delete(b.clients, ch) close(ch) b.mu.Unlock() } func (b *Broker) Publish(event CDCEvent) { b.mu.Lock() b.last = &event for ch := range b.clients { select { case ch <- event: default: } } b.mu.Unlock() } func (b *Broker) Last() *CDCEvent { b.mu.Lock() defer b.mu.Unlock() if b.last == nil { return nil } copy := *b.last return © } func writeSSE(w http.ResponseWriter, event CDCEvent) { data, err := json.Marshal(event) if err != nil { return } _, _ = fmt.Fprintf(w, "event: cdc\n") _, _ = fmt.Fprintf(w, "data: %s\n\n", data) } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func writeError(w http.ResponseWriter, status int, err error) { writeJSON(w, status, map[string]string{"error": err.Error()}) } func sleepOrDone(ctx context.Context, d time.Duration) { select { case <-ctx.Done(): case <-time.After(d): } } func logRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) }) }