Skip to main content

syncweb_core/storage/
stats_db.rs

1use std::{
2    collections::BTreeMap,
3    path::Path,
4    sync::{Arc, Mutex},
5    time::Duration,
6};
7
8use rusqlite::{Connection, params};
9
10use crate::{
11    Result, SyncwebError,
12    bandwidth_stats::{BandwidthStats, FolderStats, PeerStats},
13};
14
15#[derive(Clone, Debug)]
16pub struct StatsDatabase {
17    connection: Arc<Mutex<Connection>>,
18}
19
20impl StatsDatabase {
21    /// Open (or create) the stats database at the given path.
22    ///
23    /// # Errors
24    ///
25    /// Returns an error if the database cannot be opened or initialized.
26    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
27        let db_path = path.as_ref();
28        if let Some(parent) = db_path.parent() {
29            std::fs::create_dir_all(parent)
30                .map_err(|error| SyncwebError::operation("failed to create stats db directory", error))?;
31        }
32        let connection = Connection::open(db_path)
33            .map_err(|error| SyncwebError::operation("failed to open stats database", error))?;
34        let db = Self {
35            connection: Arc::new(Mutex::new(connection)),
36        };
37        db.init_schema()?;
38        Ok(db)
39    }
40
41    fn init_schema(&self) -> Result<()> {
42        let connection = self
43            .connection
44            .lock()
45            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
46        connection
47            .busy_timeout(std::time::Duration::from_secs(5))
48            .map_err(|error| SyncwebError::operation("failed to configure stats database", error))?;
49        connection
50            .pragma_update(None, "journal_mode", "WAL")
51            .map_err(|error| SyncwebError::operation("failed to set WAL mode", error))?;
52        connection
53            .pragma_update(None, "foreign_keys", "ON")
54            .map_err(|error| SyncwebError::operation("failed to enable foreign keys", error))?;
55        connection
56            .execute_batch(
57                "CREATE TABLE IF NOT EXISTS schema_version (
58                key TEXT PRIMARY KEY,
59                value TEXT NOT NULL
60            );
61            CREATE TABLE IF NOT EXISTS bandwidth_events (
62                id INTEGER PRIMARY KEY AUTOINCREMENT,
63                timestamp INTEGER NOT NULL,
64                direction TEXT NOT NULL CHECK(direction IN ('upload','download')),
65                bytes INTEGER NOT NULL CHECK(bytes > 0),
66                files INTEGER NOT NULL DEFAULT 1,
67                folder_namespace TEXT,
68                peer TEXT
69            );
70            CREATE INDEX IF NOT EXISTS idx_bw_ts ON bandwidth_events(timestamp);
71            CREATE INDEX IF NOT EXISTS idx_bw_folder ON bandwidth_events(folder_namespace);
72            CREATE INDEX IF NOT EXISTS idx_bw_peer ON bandwidth_events(peer);
73            CREATE TABLE IF NOT EXISTS bandwidth_period (
74                period_start INTEGER PRIMARY KEY,
75                period_end INTEGER,
76                total_upload INTEGER NOT NULL DEFAULT 0,
77                total_download INTEGER NOT NULL DEFAULT 0,
78                closed INTEGER NOT NULL DEFAULT 0
79            );
80            CREATE TABLE IF NOT EXISTS bandwidth_folder (
81                period_start INTEGER NOT NULL REFERENCES bandwidth_period(period_start),
82                folder_namespace TEXT NOT NULL,
83                upload INTEGER NOT NULL DEFAULT 0,
84                download INTEGER NOT NULL DEFAULT 0,
85                files_transferred INTEGER NOT NULL DEFAULT 0,
86                PRIMARY KEY(period_start, folder_namespace)
87            );
88            CREATE TABLE IF NOT EXISTS bandwidth_peer (
89                period_start INTEGER NOT NULL REFERENCES bandwidth_period(period_start),
90                peer TEXT NOT NULL,
91                upload INTEGER NOT NULL DEFAULT 0,
92                download INTEGER NOT NULL DEFAULT 0,
93                connection_count INTEGER NOT NULL DEFAULT 0,
94                PRIMARY KEY(period_start, peer)
95            );
96            CREATE TABLE IF NOT EXISTS daemon_log (
97                id INTEGER PRIMARY KEY AUTOINCREMENT,
98                timestamp INTEGER NOT NULL,
99                level TEXT NOT NULL CHECK(level IN ('trace','debug','info','warn','error')),
100                module TEXT,
101                message TEXT NOT NULL
102            );
103            CREATE INDEX IF NOT EXISTS idx_log_ts ON daemon_log(timestamp);
104            CREATE INDEX IF NOT EXISTS idx_log_level ON daemon_log(level);
105            CREATE TABLE IF NOT EXISTS network_events (
106                id INTEGER PRIMARY KEY AUTOINCREMENT,
107                timestamp INTEGER NOT NULL,
108                network_id TEXT NOT NULL,
109                event_type TEXT NOT NULL CHECK(event_type IN (
110                    'peer_joined','peer_left','sync_started','sync_finished',
111                    'relay_connected','relay_disconnected','relay_failed',
112                    'topic_subscribed','topic_unsubscribed',
113                    'member_added','member_removed','folder_added','folder_removed',
114                    'ticket_created','ticket_accepted','kicked'
115                )),
116                peer TEXT,
117                details TEXT,
118                metadata_json TEXT
119            );
120            CREATE INDEX IF NOT EXISTS idx_network_events_ts ON network_events(timestamp);
121            CREATE INDEX IF NOT EXISTS idx_network_events_network ON network_events(network_id);
122            CREATE INDEX IF NOT EXISTS idx_network_events_type ON network_events(event_type);
123            CREATE TABLE IF NOT EXISTS network_sync_sessions (
124                id INTEGER PRIMARY KEY AUTOINCREMENT,
125                network_id TEXT NOT NULL,
126                folder_namespace TEXT NOT NULL,
127                started_at INTEGER NOT NULL,
128                finished_at INTEGER,
129                files_transferred INTEGER NOT NULL DEFAULT 0,
130                bytes_transferred INTEGER NOT NULL DEFAULT 0,
131                errors INTEGER NOT NULL DEFAULT 0,
132                status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running','completed','failed','cancelled'))
133            );
134            CREATE INDEX IF NOT EXISTS idx_network_sessions_network ON network_sync_sessions(network_id);
135            CREATE TABLE IF NOT EXISTS relay_health (
136                relay_url TEXT NOT NULL,
137                checked_at INTEGER NOT NULL,
138                connected INTEGER NOT NULL,
139                latency_ms INTEGER,
140                error_message TEXT,
141                PRIMARY KEY(relay_url, checked_at)
142            );",
143            )
144            .map_err(|error| SyncwebError::operation("failed to initialize stats database schema", error))?;
145        let _ = connection.execute_batch("ALTER TABLE bandwidth_events ADD COLUMN network_id TEXT");
146        let _ = connection.execute_batch(
147            "CREATE VIEW IF NOT EXISTS network_bandwidth_summary AS
148             SELECT network_id,
149                    MIN(timestamp) AS period_start,
150                    MAX(timestamp) AS period_end,
151                    SUM(CASE WHEN direction='upload' THEN bytes ELSE 0 END) AS total_upload,
152                    SUM(CASE WHEN direction='download' THEN bytes ELSE 0 END) AS total_download,
153                    COUNT(DISTINCT peer) AS active_peers
154             FROM bandwidth_events
155             WHERE network_id IS NOT NULL
156             GROUP BY network_id",
157        );
158        connection
159            .execute(
160                "INSERT INTO schema_version(key, value) VALUES ('version', '1')
161             ON CONFLICT(key) DO NOTHING",
162                [],
163            )
164            .map_err(|error| SyncwebError::operation("failed to seed schema version", error))?;
165        drop(connection);
166        Ok(())
167    }
168
169    /// Record an upload bandwidth event.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the database write fails.
174    pub fn record_upload(
175        &self,
176        bytes: u64,
177        files: u64,
178        folder: Option<&str>,
179        peer: Option<&str>,
180        network_id: Option<&str>,
181    ) -> Result<()> {
182        self.record_transfer("upload", bytes, files, folder, peer, network_id)
183    }
184
185    /// Record a download bandwidth event.
186    ///
187    /// # Errors
188    ///
189    /// Returns an error if the database write fails.
190    pub fn record_download(
191        &self,
192        bytes: u64,
193        files: u64,
194        folder: Option<&str>,
195        peer: Option<&str>,
196        network_id: Option<&str>,
197    ) -> Result<()> {
198        self.record_transfer("download", bytes, files, folder, peer, network_id)
199    }
200
201    /// Record a transfer (upload or download) bandwidth event.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if the database write fails.
206    pub fn record_transfer(
207        &self,
208        direction: &str,
209        bytes: u64,
210        files: u64,
211        folder: Option<&str>,
212        peer: Option<&str>,
213        network_id: Option<&str>,
214    ) -> Result<()> {
215        let connection = self
216            .connection
217            .lock()
218            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
219        let now = current_timestamp();
220        connection
221            .execute(
222                "INSERT INTO bandwidth_events(timestamp, direction, bytes, files, folder_namespace, peer, network_id)
223             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
224                params![
225                    now,
226                    direction,
227                    bytes.cast_signed(),
228                    files.cast_signed(),
229                    folder,
230                    peer,
231                    network_id
232                ],
233            )
234            .map_err(|error| SyncwebError::operation("failed to record transfer event", error))?;
235        drop(connection);
236        Ok(())
237    }
238
239    /// Record a connection event for the given peer.
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if the database write fails.
244    pub fn record_connection(&self, peer: &str) -> Result<()> {
245        let connection = self
246            .connection
247            .lock()
248            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
249        let now = current_timestamp();
250        let period = find_or_create_period(&connection, now)
251            .map_err(|error| SyncwebError::operation("failed to find or create bandwidth period", error))?;
252        connection
253            .execute(
254                "INSERT INTO bandwidth_peer(period_start, peer, upload, download, connection_count)
255             VALUES (?1, ?2, 0, 0, 1)
256             ON CONFLICT(period_start, peer) DO UPDATE SET
257                connection_count = connection_count + 1",
258                params![period, peer],
259            )
260            .map_err(|error| SyncwebError::operation("failed to record connection", error))?;
261        drop(connection);
262        Ok(())
263    }
264
265    /// Return aggregated bandwidth statistics for the current period.
266    ///
267    /// # Errors
268    ///
269    /// Returns an error if the database cannot be queried.
270    pub fn current_stats(&self) -> Result<BandwidthStats> {
271        let connection = self
272            .connection
273            .lock()
274            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
275        let now = current_timestamp();
276        let period_start = find_or_create_period(&connection, now)
277            .map_err(|error| SyncwebError::operation("failed to find or create bandwidth period", error))?;
278
279        let (total_upload, total_download): (i64, i64) = connection
280            .query_row(
281                "SELECT COALESCE(SUM(CASE WHEN direction='upload' THEN bytes ELSE 0 END), 0),
282                    COALESCE(SUM(CASE WHEN direction='download' THEN bytes ELSE 0 END), 0)
283             FROM bandwidth_events WHERE timestamp >= ?1",
284                params![period_start],
285                |row| Ok((row.get(0)?, row.get(1)?)),
286            )
287            .map_err(|error| SyncwebError::operation("failed to query bandwidth totals", error))?;
288
289        let mut per_folder = BTreeMap::new();
290        let mut folder_stmt = connection
291            .prepare(
292                "SELECT folder_namespace, upload, download, files_transferred
293             FROM bandwidth_folder WHERE period_start = ?1",
294            )
295            .map_err(|error| SyncwebError::operation("failed to prepare folder stats query", error))?;
296        let folder_rows = folder_stmt
297            .query_map(params![period_start], |row| {
298                Ok((
299                    row.get::<_, String>(0)?,
300                    row.get::<_, i64>(1)?,
301                    row.get::<_, i64>(2)?,
302                    row.get::<_, i64>(3)?,
303                ))
304            })
305            .map_err(|error| SyncwebError::operation("failed to query folder stats", error))?
306            .collect::<std::result::Result<Vec<_>, _>>()
307            .map_err(|error| SyncwebError::operation("failed to read folder stats", error))?;
308        for (ns, upload, download, files) in folder_rows {
309            per_folder.insert(
310                ns,
311                FolderStats {
312                    upload: upload.cast_unsigned(),
313                    download: download.cast_unsigned(),
314                    files_transferred: files.cast_unsigned(),
315                },
316            );
317        }
318
319        let mut per_peer = BTreeMap::new();
320        let mut peer_stmt = connection
321            .prepare(
322                "SELECT peer, upload, download, connection_count
323             FROM bandwidth_peer WHERE period_start = ?1",
324            )
325            .map_err(|error| SyncwebError::operation("failed to prepare peer stats query", error))?;
326        let peer_rows = peer_stmt
327            .query_map(params![period_start], |row| {
328                Ok((
329                    row.get::<_, String>(0)?,
330                    row.get::<_, i64>(1)?,
331                    row.get::<_, i64>(2)?,
332                    row.get::<_, i64>(3)?,
333                ))
334            })
335            .map_err(|error| SyncwebError::operation("failed to query peer stats", error))?
336            .collect::<std::result::Result<Vec<_>, _>>()
337            .map_err(|error| SyncwebError::operation("failed to read peer stats", error))?;
338        for (peer, upload, download, conns) in peer_rows {
339            per_peer.insert(
340                peer,
341                PeerStats {
342                    upload: upload.cast_unsigned(),
343                    download: download.cast_unsigned(),
344                    connection_count: conns.cast_unsigned(),
345                },
346            );
347        }
348
349        drop(folder_stmt);
350        drop(peer_stmt);
351        drop(connection);
352        Ok(BandwidthStats {
353            total_upload: total_upload.cast_unsigned(),
354            total_download: total_download.cast_unsigned(),
355            per_folder,
356            per_peer,
357            period_start: period_start.cast_unsigned(),
358        })
359    }
360
361    /// Delete all persisted bandwidth counters and transfer events across every
362    /// period, zeroing the totals reported by [`Self::current_stats`].
363    ///
364    /// # Errors
365    ///
366    /// Returns an error if the database operation fails.
367    pub fn reset_bandwidth(&self) -> Result<()> {
368        let connection = self
369            .connection
370            .lock()
371            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
372        connection
373            .execute("DELETE FROM bandwidth_events", [])
374            .map_err(|error| SyncwebError::operation("failed to clear bandwidth events", error))?;
375        connection
376            .execute("DELETE FROM bandwidth_folder", [])
377            .map_err(|error| SyncwebError::operation("failed to clear folder bandwidth", error))?;
378        connection
379            .execute("DELETE FROM bandwidth_peer", [])
380            .map_err(|error| SyncwebError::operation("failed to clear peer bandwidth", error))?;
381        connection
382            .execute("DELETE FROM bandwidth_period", [])
383            .map_err(|error| SyncwebError::operation("failed to clear bandwidth periods", error))?;
384        drop(connection);
385        Ok(())
386    }
387
388    /// Append a daemon log entry.
389    ///
390    /// # Errors
391    ///
392    /// Returns an error if the database write fails.
393    pub fn append_log(&self, level: &str, module: Option<&str>, message: &str) -> Result<()> {
394        let connection = self
395            .connection
396            .lock()
397            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
398        let now = current_timestamp();
399        connection
400            .execute(
401                "INSERT INTO daemon_log(timestamp, level, module, message) VALUES (?1, ?2, ?3, ?4)",
402                params![now, level, module, message],
403            )
404            .map_err(|error| SyncwebError::operation("failed to append log entry", error))?;
405        drop(connection);
406        Ok(())
407    }
408
409    /// Delete bandwidth events older than the given duration.
410    ///
411    /// # Errors
412    ///
413    /// Returns an error if the database operation fails.
414    pub fn purge_old_bandwidth(&self, older_than: Duration) -> Result<usize> {
415        let connection = self
416            .connection
417            .lock()
418            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
419        let cutoff = current_timestamp().saturating_sub(older_than.as_secs().cast_signed());
420        let deleted = connection
421            .execute("DELETE FROM bandwidth_events WHERE timestamp < ?1", params![cutoff])
422            .map_err(|error| SyncwebError::operation("failed to purge old bandwidth events", error))?;
423        drop(connection);
424        Ok(deleted)
425    }
426
427    /// Record a network event.
428    ///
429    /// # Errors
430    ///
431    /// Returns an error if the database write fails.
432    pub fn record_network_event(
433        &self,
434        network_id: &str,
435        event_type: &str,
436        peer: Option<&str>,
437        details: Option<&str>,
438        metadata_json: Option<&str>,
439    ) -> Result<()> {
440        let connection = self
441            .connection
442            .lock()
443            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
444        let now = current_timestamp();
445        connection
446            .execute(
447                "INSERT INTO network_events(timestamp, network_id, event_type, peer, details, metadata_json)
448             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
449                params![now, network_id, event_type, peer, details, metadata_json],
450            )
451            .map_err(|error| SyncwebError::operation("failed to record network event", error))?;
452        drop(connection);
453        Ok(())
454    }
455
456    /// Record a sync session start and return its session ID.
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if the database write fails.
461    pub fn record_sync_session_start(&self, network_id: &str, folder_namespace: &str) -> Result<i64> {
462        let connection = self
463            .connection
464            .lock()
465            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
466        let now = current_timestamp();
467        connection
468            .execute(
469                "INSERT INTO network_sync_sessions(network_id, folder_namespace, started_at)
470             VALUES (?1, ?2, ?3)",
471                params![network_id, folder_namespace, now],
472            )
473            .map_err(|error| SyncwebError::operation("failed to record sync session start", error))?;
474        let session_id = connection.last_insert_rowid();
475        drop(connection);
476        Ok(session_id)
477    }
478
479    /// Record a sync session finish.
480    ///
481    /// # Errors
482    ///
483    /// Returns an error if the database write fails.
484    pub fn record_sync_session_finish(
485        &self,
486        session_id: i64,
487        files: u64,
488        bytes: u64,
489        errors: u64,
490        status: &str,
491    ) -> Result<()> {
492        let connection = self
493            .connection
494            .lock()
495            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
496        let now = current_timestamp();
497        connection
498            .execute(
499                "UPDATE network_sync_sessions SET finished_at = ?1, files_transferred = ?2,
500                 bytes_transferred = ?3, errors = ?4, status = ?5 WHERE id = ?6",
501                params![
502                    now,
503                    files.cast_signed(),
504                    bytes.cast_signed(),
505                    errors.cast_signed(),
506                    status,
507                    session_id
508                ],
509            )
510            .map_err(|error| SyncwebError::operation("failed to record sync session finish", error))?;
511        drop(connection);
512        Ok(())
513    }
514
515    /// Record a relay health check.
516    ///
517    /// # Errors
518    ///
519    /// Returns an error if the database write fails.
520    pub fn record_relay_check(
521        &self,
522        relay_url: &str,
523        connected: bool,
524        latency_ms: Option<i64>,
525        error_message: Option<&str>,
526    ) -> Result<()> {
527        let connection = self
528            .connection
529            .lock()
530            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
531        let now = current_timestamp();
532        connection
533            .execute(
534                "INSERT INTO relay_health(relay_url, checked_at, connected, latency_ms, error_message)
535             VALUES (?1, ?2, ?3, ?4, ?5)",
536                params![relay_url, now, i64::from(connected), latency_ms, error_message],
537            )
538            .map_err(|error| SyncwebError::operation("failed to record relay check", error))?;
539        drop(connection);
540        Ok(())
541    }
542
543    /// Query recent network events for a given network.
544    ///
545    /// # Errors
546    ///
547    /// Returns an error if the database query fails.
548    pub fn recent_network_events(&self, network_id: &str, limit: usize) -> Result<Vec<NetworkEventRecord>> {
549        let connection = self
550            .connection
551            .lock()
552            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
553        let mut stmt = connection
554            .prepare(
555                "SELECT id, timestamp, network_id, event_type, peer, details, metadata_json
556             FROM network_events WHERE network_id = ?1
557             ORDER BY timestamp DESC LIMIT ?2",
558            )
559            .map_err(|error| SyncwebError::operation("failed to prepare network events query", error))?;
560        let records = stmt
561            .query_map(params![network_id, limit.cast_signed()], |row| {
562                Ok(NetworkEventRecord {
563                    id: row.get(0)?,
564                    timestamp: row.get(1)?,
565                    network_id: row.get(2)?,
566                    event_type: row.get(3)?,
567                    peer: row.get(4)?,
568                    details: row.get(5)?,
569                    metadata_json: row.get(6)?,
570                })
571            })
572            .map_err(|error| SyncwebError::operation("failed to query network events", error))?
573            .collect::<std::result::Result<Vec<_>, _>>()
574            .map_err(|error| SyncwebError::operation("failed to read network event rows", error))?;
575        drop(stmt);
576        drop(connection);
577        Ok(records)
578    }
579
580    /// Query recent sync sessions for a given network.
581    ///
582    /// # Errors
583    ///
584    /// Returns an error if the database query fails.
585    pub fn recent_sync_sessions(&self, network_id: &str, limit: usize) -> Result<Vec<SyncSessionRecord>> {
586        let connection = self
587            .connection
588            .lock()
589            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
590        let mut stmt = connection
591            .prepare(
592                "SELECT id, network_id, folder_namespace, started_at, finished_at,
593                 files_transferred, bytes_transferred, errors, status
594             FROM network_sync_sessions WHERE network_id = ?1
595             ORDER BY started_at DESC LIMIT ?2",
596            )
597            .map_err(|error| SyncwebError::operation("failed to prepare sync sessions query", error))?;
598        let records = stmt
599            .query_map(params![network_id, limit.cast_signed()], |row| {
600                Ok(SyncSessionRecord {
601                    id: row.get(0)?,
602                    network_id: row.get(1)?,
603                    folder_namespace: row.get(2)?,
604                    started_at: row.get(3)?,
605                    finished_at: row.get(4)?,
606                    files_transferred: row.get::<_, i64>(5)?.cast_unsigned(),
607                    bytes_transferred: row.get::<_, i64>(6)?.cast_unsigned(),
608                    errors: row.get::<_, i64>(7)?.cast_unsigned(),
609                    status: row.get(8)?,
610                })
611            })
612            .map_err(|error| SyncwebError::operation("failed to query sync sessions", error))?
613            .collect::<std::result::Result<Vec<_>, _>>()
614            .map_err(|error| SyncwebError::operation("failed to read sync session rows", error))?;
615        drop(stmt);
616        drop(connection);
617        Ok(records)
618    }
619
620    /// Calculate relay uptime as a ratio (0.0-1.0) over the given time window.
621    ///
622    /// # Errors
623    ///
624    /// Returns an error if the database query fails.
625    pub fn relay_uptime(&self, relay_url: &str, window_secs: i64) -> Result<f64> {
626        let connection = self
627            .connection
628            .lock()
629            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
630        let cutoff = current_timestamp().saturating_sub(window_secs);
631        let (total, connected): (i64, i64) = connection
632            .query_row(
633                "SELECT COUNT(*), SUM(connected) FROM relay_health
634             WHERE relay_url = ?1 AND checked_at >= ?2",
635                params![relay_url, cutoff],
636                |row| Ok((row.get(0)?, row.get::<_, Option<i64>>(1)?.unwrap_or(0))),
637            )
638            .map_err(|error| SyncwebError::operation("failed to query relay uptime", error))?;
639        drop(connection);
640        Ok(if total > 0 {
641            f64::from(i32::try_from(connected).unwrap_or(0))
642                .mul_add(f64::from(i32::try_from(total).unwrap_or(1)).recip(), 0.0)
643        } else {
644            1.0
645        })
646    }
647
648    /// Run VACUUM to reclaim space.
649    ///
650    /// # Errors
651    ///
652    /// Returns an error if the database operation fails.
653    pub fn vacuum(&self) -> Result<()> {
654        let connection = self
655            .connection
656            .lock()
657            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
658        connection
659            .execute_batch("VACUUM")
660            .map_err(|error| SyncwebError::operation("vacuum failed", error))?;
661        drop(connection);
662        Ok(())
663    }
664
665    /// Return estimated database size in bytes.
666    ///
667    /// # Errors
668    ///
669    /// Returns an error if the file metadata cannot be read.
670    pub fn size_on_disk(&self) -> Result<u64> {
671        let connection = self
672            .connection
673            .lock()
674            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
675        let db_path: String = connection
676            .query_row("PRAGMA database_list", [], |row| row.get(2))
677            .map_err(|error| SyncwebError::operation("failed to query database path", error))?;
678        drop(connection);
679        std::fs::metadata(&db_path)
680            .map(|meta| meta.len())
681            .map_err(|error| SyncwebError::operation("failed to get database file size", error))
682    }
683
684    /// Return the freelist page count (indicates whether VACUUM would help).
685    ///
686    /// # Errors
687    ///
688    /// Returns an error if the database operation fails.
689    pub fn freelist_count(&self) -> Result<i64> {
690        let connection = self
691            .connection
692            .lock()
693            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
694        let count: i64 = connection
695            .pragma_query_value(None, "freelist_count", |row| row.get(0))
696            .map_err(|error| SyncwebError::operation("failed to query freelist count", error))?;
697        drop(connection);
698        Ok(count)
699    }
700
701    /// Run `integrity_check` PRAGMA. Returns list of errors (empty = healthy).
702    ///
703    /// # Errors
704    ///
705    /// Returns an error if the database operation fails.
706    pub fn check_integrity(&self) -> Result<Vec<String>> {
707        let connection = self
708            .connection
709            .lock()
710            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
711        let mut stmt = connection
712            .prepare("PRAGMA integrity_check")
713            .map_err(|error| SyncwebError::operation("failed to prepare integrity check", error))?;
714        let errors: Vec<String> = stmt
715            .query_map([], |row| row.get::<_, String>(0))
716            .map_err(|error| SyncwebError::operation("failed to run integrity check", error))?
717            .filter_map(std::result::Result::ok)
718            .filter(|s| s != "ok")
719            .collect();
720        drop(stmt);
721        drop(connection);
722        Ok(errors)
723    }
724
725    /// Delete daemon log entries older than the given duration.
726    ///
727    /// # Errors
728    ///
729    /// Returns an error if the database operation fails.
730    pub fn purge_old_logs(&self, older_than: Duration) -> Result<usize> {
731        let connection = self
732            .connection
733            .lock()
734            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
735        let cutoff = current_timestamp().saturating_sub(older_than.as_secs().cast_signed());
736        let deleted = connection
737            .execute("DELETE FROM daemon_log WHERE timestamp < ?1", params![cutoff])
738            .map_err(|error| SyncwebError::operation("failed to purge old log entries", error))?;
739        drop(connection);
740        Ok(deleted)
741    }
742}
743
744/// A record from the `network_events` table.
745#[derive(Clone, Debug)]
746#[non_exhaustive]
747pub struct NetworkEventRecord {
748    pub id: i64,
749    pub timestamp: i64,
750    pub network_id: String,
751    pub event_type: String,
752    pub peer: Option<String>,
753    pub details: Option<String>,
754    pub metadata_json: Option<String>,
755}
756
757/// A record from the `network_sync_sessions` table.
758#[derive(Clone, Debug)]
759#[non_exhaustive]
760pub struct SyncSessionRecord {
761    pub id: i64,
762    pub network_id: String,
763    pub folder_namespace: String,
764    pub started_at: i64,
765    pub finished_at: Option<i64>,
766    pub files_transferred: u64,
767    pub bytes_transferred: u64,
768    pub errors: u64,
769    pub status: String,
770}
771
772fn current_timestamp() -> i64 {
773    std::time::SystemTime::now()
774        .duration_since(std::time::UNIX_EPOCH)
775        .map_or(0, |d| d.as_secs().cast_signed())
776}
777
778impl crate::storage::Vacuumable for StatsDatabase {
779    fn vacuum(&self) -> Result<()> {
780        self.vacuum()
781    }
782
783    fn freelist_count(&self) -> Result<i64> {
784        self.freelist_count()
785    }
786}
787
788fn find_or_create_period(connection: &Connection, now: i64) -> std::result::Result<i64, rusqlite::Error> {
789    let period_start = now.div_euclid(3600).saturating_mul(3600);
790    connection.execute(
791        "INSERT OR IGNORE INTO bandwidth_period(period_start, total_upload, total_download, closed)
792         VALUES (?1, 0, 0, 0)",
793        params![period_start],
794    )?;
795    Ok(period_start)
796}