diff --git a/cmd/server/main.go b/cmd/server/main.go index 17b9c0fe..942d2a75 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -180,6 +180,12 @@ func main() { log.Printf("[store] warning: could not add observers.inactive column: %v", err) } + // Ensure observers.last_packet_at column exists (PR #905 reads it; ingestor migration + // adds it but server may run against DBs ingestor never touched, e.g. e2e fixture). + if err := ensureLastPacketAtColumn(dbPath); err != nil { + log.Printf("[store] warning: could not add observers.last_packet_at column: %v", err) + } + // Soft-delete observers that are in the blacklist (mark inactive=1) so // historical data from a prior unblocked window is hidden too. if len(cfg.ObserverBlacklist) > 0 { diff --git a/cmd/server/neighbor_persist.go b/cmd/server/neighbor_persist.go index 58637e86..675772a6 100644 --- a/cmd/server/neighbor_persist.go +++ b/cmd/server/neighbor_persist.go @@ -320,6 +320,44 @@ func ensureObserverInactiveColumn(dbPath string) error { return nil } +// ensureLastPacketAtColumn adds the last_packet_at column to observers if missing. +// The column was originally added by ingestor migration (observers_last_packet_at_v1) +// to track the most recent packet observation time separately from status updates. +// When the server starts against a DB that was never touched by the ingestor (e.g. +// the e2e fixture), the column is missing and read queries that reference it +// (GetObservers, GetObserverByID) fail with "no such column: last_packet_at". +func ensureLastPacketAtColumn(dbPath string) error { + rw, err := openRW(dbPath) + if err != nil { + return err + } + defer rw.Close() + + rows, err := rw.Query("PRAGMA table_info(observers)") + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var cid int + var colName string + var colType sql.NullString + var notNull, pk int + var dflt sql.NullString + if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "last_packet_at" { + return nil // already exists + } + } + + _, err = rw.Exec("ALTER TABLE observers ADD COLUMN last_packet_at TEXT") + if err != nil { + return fmt.Errorf("add last_packet_at column: %w", err) + } + log.Println("[store] Added last_packet_at column to observers") + return nil +} + // softDeleteBlacklistedObservers marks observers matching the blacklist as // inactive=1 so they are hidden from API responses. Runs once at startup. func softDeleteBlacklistedObservers(dbPath string, blacklist []string) { diff --git a/cmd/server/neighbor_persist_test.go b/cmd/server/neighbor_persist_test.go index 33d29efc..40594e4e 100644 --- a/cmd/server/neighbor_persist_test.go +++ b/cmd/server/neighbor_persist_test.go @@ -538,3 +538,62 @@ func TestOpenRW_BusyTimeout(t *testing.T) { t.Errorf("expected busy_timeout=5000, got %d", timeout) } } + +func TestEnsureLastPacketAtColumn(t *testing.T) { + // Create a temp DB with observers table missing last_packet_at + dir := t.TempDir() + dbPath := dir + "/test.db" + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(`CREATE TABLE observers ( + id TEXT PRIMARY KEY, + name TEXT, + last_seen TEXT, + lat REAL, + lon REAL, + inactive INTEGER DEFAULT 0 + )`) + if err != nil { + t.Fatal(err) + } + db.Close() + + // First call: should add the column + if err := ensureLastPacketAtColumn(dbPath); err != nil { + t.Fatalf("first call failed: %v", err) + } + + // Verify column exists + db2, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + defer db2.Close() + + var found bool + rows, err := db2.Query("PRAGMA table_info(observers)") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var cid int + var colName string + var colType sql.NullString + var notNull, pk int + var dflt sql.NullString + if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "last_packet_at" { + found = true + } + } + if !found { + t.Fatal("last_packet_at column not found after migration") + } + + // Idempotency: second call should succeed without error + if err := ensureLastPacketAtColumn(dbPath); err != nil { + t.Fatalf("idempotent call failed: %v", err) + } +}