package store import ( "database/sql" "encoding/json" "time" ) // On-demand app log tails (Part D) — the pull-based counterpart of the controller's // log_tails report section. The hub stores an operator intent in log_tail_requests, // advertises it on the report ACK (log_tail_requests: [app…]), and when the tail lands // on the NEXT report it is stored here and the request is cleared (consume-once). // Tails are transient custody: the last 2 per (customer, app) are kept, older pruned. // AppLogTail is one received, ordered log tail. type AppLogTail struct { ID int CustomerID string AppName string CollectedAt time.Time ReceivedAt time.Time Lines []string } // RequestLogTail records (or refreshes) the operator's pending request for one app's // log tail. One active request per (customer, app) — a re-click refreshes requested_at. func (s *Store) RequestLogTail(customerID, appName string) error { _, err := s.db.Exec(` INSERT INTO log_tail_requests (customer_id, app_name, requested_at) VALUES (?, ?, ?) ON CONFLICT(customer_id, app_name) DO UPDATE SET requested_at = excluded.requested_at`, customerID, appName, time.Now().UTC()) return err } // GetPendingLogTailRequests returns the app names with a pending tail request for the // customer — the value advertised on the report ACK. nil/empty = nothing pending. func (s *Store) GetPendingLogTailRequests(customerID string) ([]string, error) { rows, err := s.db.Query(`SELECT app_name FROM log_tail_requests WHERE customer_id = ? ORDER BY app_name`, customerID) if err != nil { return nil, err } defer rows.Close() var apps []string for rows.Next() { var app string if err := rows.Scan(&app); err == nil { apps = append(apps, app) } } return apps, rows.Err() } // SaveAppLogTail stores a received tail, prunes to the newest 2 per (customer, app), // and clears the pending request (consume-once — without the clear the ACK would keep // advertising the request and the controller would ship the tail every cycle). func (s *Store) SaveAppLogTail(customerID, appName string, collectedAt time.Time, lines []string) error { linesJSON, err := json.Marshal(lines) if err != nil { return err } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(` INSERT INTO app_log_tails (customer_id, app_name, collected_at, received_at, lines_json) VALUES (?, ?, ?, ?, ?)`, customerID, appName, collectedAt, time.Now().UTC(), string(linesJSON)); err != nil { return err } // Keep the newest 2 per (customer, app). if _, err := tx.Exec(` DELETE FROM app_log_tails WHERE customer_id = ? AND app_name = ? AND id NOT IN ( SELECT id FROM app_log_tails WHERE customer_id = ? AND app_name = ? ORDER BY id DESC LIMIT 2 )`, customerID, appName, customerID, appName); err != nil { return err } // Consume-once: the fulfilled request is cleared. if _, err := tx.Exec(`DELETE FROM log_tail_requests WHERE customer_id = ? AND app_name = ?`, customerID, appName); err != nil { return err } return tx.Commit() } // GetCustomerLogTails returns all stored tails for a customer, newest first. func (s *Store) GetCustomerLogTails(customerID string) ([]AppLogTail, error) { rows, err := s.db.Query(` SELECT id, customer_id, app_name, collected_at, received_at, lines_json FROM app_log_tails WHERE customer_id = ? ORDER BY id DESC`, customerID) if err != nil { return nil, err } defer rows.Close() var tails []AppLogTail for rows.Next() { t, err := scanLogTail(rows) if err != nil { continue } tails = append(tails, t) } return tails, rows.Err() } // GetLogTail returns one stored tail by id, scoped to the customer (no cross-customer reads). func (s *Store) GetLogTail(id int, customerID string) (*AppLogTail, error) { rows, err := s.db.Query(` SELECT id, customer_id, app_name, collected_at, received_at, lines_json FROM app_log_tails WHERE id = ? AND customer_id = ?`, id, customerID) if err != nil { return nil, err } defer rows.Close() if !rows.Next() { return nil, rows.Err() } t, err := scanLogTail(rows) if err != nil { return nil, err } return &t, nil } func scanLogTail(rows *sql.Rows) (AppLogTail, error) { var t AppLogTail var linesJSON string if err := rows.Scan(&t.ID, &t.CustomerID, &t.AppName, &t.CollectedAt, &t.ReceivedAt, &linesJSON); err != nil { return t, err } json.Unmarshal([]byte(linesJSON), &t.Lines) return t, nil }