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    /// Append a daemon log entry.
362    ///
363    /// # Errors
364    ///
365    /// Returns an error if the database write fails.
366    pub fn append_log(&self, level: &str, module: Option<&str>, message: &str) -> Result<()> {
367        let connection = self
368            .connection
369            .lock()
370            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
371        let now = current_timestamp();
372        connection
373            .execute(
374                "INSERT INTO daemon_log(timestamp, level, module, message) VALUES (?1, ?2, ?3, ?4)",
375                params![now, level, module, message],
376            )
377            .map_err(|error| SyncwebError::operation("failed to append log entry", error))?;
378        drop(connection);
379        Ok(())
380    }
381
382    /// Delete bandwidth events older than the given duration.
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if the database operation fails.
387    pub fn purge_old_bandwidth(&self, older_than: Duration) -> Result<usize> {
388        let connection = self
389            .connection
390            .lock()
391            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
392        let cutoff = current_timestamp().saturating_sub(older_than.as_secs().cast_signed());
393        let deleted = connection
394            .execute("DELETE FROM bandwidth_events WHERE timestamp < ?1", params![cutoff])
395            .map_err(|error| SyncwebError::operation("failed to purge old bandwidth events", error))?;
396        drop(connection);
397        Ok(deleted)
398    }
399
400    /// Record a network event.
401    ///
402    /// # Errors
403    ///
404    /// Returns an error if the database write fails.
405    pub fn record_network_event(
406        &self,
407        network_id: &str,
408        event_type: &str,
409        peer: Option<&str>,
410        details: Option<&str>,
411        metadata_json: Option<&str>,
412    ) -> Result<()> {
413        let connection = self
414            .connection
415            .lock()
416            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
417        let now = current_timestamp();
418        connection
419            .execute(
420                "INSERT INTO network_events(timestamp, network_id, event_type, peer, details, metadata_json)
421             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
422                params![now, network_id, event_type, peer, details, metadata_json],
423            )
424            .map_err(|error| SyncwebError::operation("failed to record network event", error))?;
425        drop(connection);
426        Ok(())
427    }
428
429    /// Record a sync session start and return its session ID.
430    ///
431    /// # Errors
432    ///
433    /// Returns an error if the database write fails.
434    pub fn record_sync_session_start(&self, network_id: &str, folder_namespace: &str) -> Result<i64> {
435        let connection = self
436            .connection
437            .lock()
438            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
439        let now = current_timestamp();
440        connection
441            .execute(
442                "INSERT INTO network_sync_sessions(network_id, folder_namespace, started_at)
443             VALUES (?1, ?2, ?3)",
444                params![network_id, folder_namespace, now],
445            )
446            .map_err(|error| SyncwebError::operation("failed to record sync session start", error))?;
447        let session_id = connection.last_insert_rowid();
448        drop(connection);
449        Ok(session_id)
450    }
451
452    /// Record a sync session finish.
453    ///
454    /// # Errors
455    ///
456    /// Returns an error if the database write fails.
457    pub fn record_sync_session_finish(
458        &self,
459        session_id: i64,
460        files: u64,
461        bytes: u64,
462        errors: u64,
463        status: &str,
464    ) -> Result<()> {
465        let connection = self
466            .connection
467            .lock()
468            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
469        let now = current_timestamp();
470        connection
471            .execute(
472                "UPDATE network_sync_sessions SET finished_at = ?1, files_transferred = ?2,
473                 bytes_transferred = ?3, errors = ?4, status = ?5 WHERE id = ?6",
474                params![
475                    now,
476                    files.cast_signed(),
477                    bytes.cast_signed(),
478                    errors.cast_signed(),
479                    status,
480                    session_id
481                ],
482            )
483            .map_err(|error| SyncwebError::operation("failed to record sync session finish", error))?;
484        drop(connection);
485        Ok(())
486    }
487
488    /// Record a relay health check.
489    ///
490    /// # Errors
491    ///
492    /// Returns an error if the database write fails.
493    pub fn record_relay_check(
494        &self,
495        relay_url: &str,
496        connected: bool,
497        latency_ms: Option<i64>,
498        error_message: Option<&str>,
499    ) -> Result<()> {
500        let connection = self
501            .connection
502            .lock()
503            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
504        let now = current_timestamp();
505        connection
506            .execute(
507                "INSERT INTO relay_health(relay_url, checked_at, connected, latency_ms, error_message)
508             VALUES (?1, ?2, ?3, ?4, ?5)",
509                params![relay_url, now, i64::from(connected), latency_ms, error_message],
510            )
511            .map_err(|error| SyncwebError::operation("failed to record relay check", error))?;
512        drop(connection);
513        Ok(())
514    }
515
516    /// Query recent network events for a given network.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if the database query fails.
521    pub fn recent_network_events(&self, network_id: &str, limit: usize) -> Result<Vec<NetworkEventRecord>> {
522        let connection = self
523            .connection
524            .lock()
525            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
526        let mut stmt = connection
527            .prepare(
528                "SELECT id, timestamp, network_id, event_type, peer, details, metadata_json
529             FROM network_events WHERE network_id = ?1
530             ORDER BY timestamp DESC LIMIT ?2",
531            )
532            .map_err(|error| SyncwebError::operation("failed to prepare network events query", error))?;
533        let records = stmt
534            .query_map(params![network_id, limit.cast_signed()], |row| {
535                Ok(NetworkEventRecord {
536                    id: row.get(0)?,
537                    timestamp: row.get(1)?,
538                    network_id: row.get(2)?,
539                    event_type: row.get(3)?,
540                    peer: row.get(4)?,
541                    details: row.get(5)?,
542                    metadata_json: row.get(6)?,
543                })
544            })
545            .map_err(|error| SyncwebError::operation("failed to query network events", error))?
546            .collect::<std::result::Result<Vec<_>, _>>()
547            .map_err(|error| SyncwebError::operation("failed to read network event rows", error))?;
548        drop(stmt);
549        drop(connection);
550        Ok(records)
551    }
552
553    /// Query recent sync sessions for a given network.
554    ///
555    /// # Errors
556    ///
557    /// Returns an error if the database query fails.
558    pub fn recent_sync_sessions(&self, network_id: &str, limit: usize) -> Result<Vec<SyncSessionRecord>> {
559        let connection = self
560            .connection
561            .lock()
562            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
563        let mut stmt = connection
564            .prepare(
565                "SELECT id, network_id, folder_namespace, started_at, finished_at,
566                 files_transferred, bytes_transferred, errors, status
567             FROM network_sync_sessions WHERE network_id = ?1
568             ORDER BY started_at DESC LIMIT ?2",
569            )
570            .map_err(|error| SyncwebError::operation("failed to prepare sync sessions query", error))?;
571        let records = stmt
572            .query_map(params![network_id, limit.cast_signed()], |row| {
573                Ok(SyncSessionRecord {
574                    id: row.get(0)?,
575                    network_id: row.get(1)?,
576                    folder_namespace: row.get(2)?,
577                    started_at: row.get(3)?,
578                    finished_at: row.get(4)?,
579                    files_transferred: row.get::<_, i64>(5)?.cast_unsigned(),
580                    bytes_transferred: row.get::<_, i64>(6)?.cast_unsigned(),
581                    errors: row.get::<_, i64>(7)?.cast_unsigned(),
582                    status: row.get(8)?,
583                })
584            })
585            .map_err(|error| SyncwebError::operation("failed to query sync sessions", error))?
586            .collect::<std::result::Result<Vec<_>, _>>()
587            .map_err(|error| SyncwebError::operation("failed to read sync session rows", error))?;
588        drop(stmt);
589        drop(connection);
590        Ok(records)
591    }
592
593    /// Calculate relay uptime as a ratio (0.0-1.0) over the given time window.
594    ///
595    /// # Errors
596    ///
597    /// Returns an error if the database query fails.
598    pub fn relay_uptime(&self, relay_url: &str, window_secs: i64) -> Result<f64> {
599        let connection = self
600            .connection
601            .lock()
602            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
603        let cutoff = current_timestamp().saturating_sub(window_secs);
604        let (total, connected): (i64, i64) = connection
605            .query_row(
606                "SELECT COUNT(*), SUM(connected) FROM relay_health
607             WHERE relay_url = ?1 AND checked_at >= ?2",
608                params![relay_url, cutoff],
609                |row| Ok((row.get(0)?, row.get::<_, Option<i64>>(1)?.unwrap_or(0))),
610            )
611            .map_err(|error| SyncwebError::operation("failed to query relay uptime", error))?;
612        drop(connection);
613        Ok(if total > 0 {
614            f64::from(i32::try_from(connected).unwrap_or(0))
615                .mul_add(f64::from(i32::try_from(total).unwrap_or(1)).recip(), 0.0)
616        } else {
617            1.0
618        })
619    }
620
621    /// Run VACUUM to reclaim space.
622    ///
623    /// # Errors
624    ///
625    /// Returns an error if the database operation fails.
626    pub fn vacuum(&self) -> Result<()> {
627        let connection = self
628            .connection
629            .lock()
630            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
631        connection
632            .execute_batch("VACUUM")
633            .map_err(|error| SyncwebError::operation("vacuum failed", error))?;
634        drop(connection);
635        Ok(())
636    }
637
638    /// Return estimated database size in bytes.
639    ///
640    /// # Errors
641    ///
642    /// Returns an error if the file metadata cannot be read.
643    pub fn size_on_disk(&self) -> Result<u64> {
644        let connection = self
645            .connection
646            .lock()
647            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
648        let db_path: String = connection
649            .query_row("PRAGMA database_list", [], |row| row.get(2))
650            .map_err(|error| SyncwebError::operation("failed to query database path", error))?;
651        drop(connection);
652        std::fs::metadata(&db_path)
653            .map(|meta| meta.len())
654            .map_err(|error| SyncwebError::operation("failed to get database file size", error))
655    }
656
657    /// Return the freelist page count (indicates whether VACUUM would help).
658    ///
659    /// # Errors
660    ///
661    /// Returns an error if the database operation fails.
662    pub fn freelist_count(&self) -> Result<i64> {
663        let connection = self
664            .connection
665            .lock()
666            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
667        let count: i64 = connection
668            .pragma_query_value(None, "freelist_count", |row| row.get(0))
669            .map_err(|error| SyncwebError::operation("failed to query freelist count", error))?;
670        drop(connection);
671        Ok(count)
672    }
673
674    /// Run `integrity_check` PRAGMA. Returns list of errors (empty = healthy).
675    ///
676    /// # Errors
677    ///
678    /// Returns an error if the database operation fails.
679    pub fn check_integrity(&self) -> Result<Vec<String>> {
680        let connection = self
681            .connection
682            .lock()
683            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
684        let mut stmt = connection
685            .prepare("PRAGMA integrity_check")
686            .map_err(|error| SyncwebError::operation("failed to prepare integrity check", error))?;
687        let errors: Vec<String> = stmt
688            .query_map([], |row| row.get::<_, String>(0))
689            .map_err(|error| SyncwebError::operation("failed to run integrity check", error))?
690            .filter_map(std::result::Result::ok)
691            .filter(|s| s != "ok")
692            .collect();
693        drop(stmt);
694        drop(connection);
695        Ok(errors)
696    }
697
698    /// Delete daemon log entries older than the given duration.
699    ///
700    /// # Errors
701    ///
702    /// Returns an error if the database operation fails.
703    pub fn purge_old_logs(&self, older_than: Duration) -> Result<usize> {
704        let connection = self
705            .connection
706            .lock()
707            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
708        let cutoff = current_timestamp().saturating_sub(older_than.as_secs().cast_signed());
709        let deleted = connection
710            .execute("DELETE FROM daemon_log WHERE timestamp < ?1", params![cutoff])
711            .map_err(|error| SyncwebError::operation("failed to purge old log entries", error))?;
712        drop(connection);
713        Ok(deleted)
714    }
715}
716
717/// A record from the `network_events` table.
718#[derive(Clone, Debug)]
719#[non_exhaustive]
720pub struct NetworkEventRecord {
721    pub id: i64,
722    pub timestamp: i64,
723    pub network_id: String,
724    pub event_type: String,
725    pub peer: Option<String>,
726    pub details: Option<String>,
727    pub metadata_json: Option<String>,
728}
729
730/// A record from the `network_sync_sessions` table.
731#[derive(Clone, Debug)]
732#[non_exhaustive]
733pub struct SyncSessionRecord {
734    pub id: i64,
735    pub network_id: String,
736    pub folder_namespace: String,
737    pub started_at: i64,
738    pub finished_at: Option<i64>,
739    pub files_transferred: u64,
740    pub bytes_transferred: u64,
741    pub errors: u64,
742    pub status: String,
743}
744
745fn current_timestamp() -> i64 {
746    std::time::SystemTime::now()
747        .duration_since(std::time::UNIX_EPOCH)
748        .map_or(0, |d| d.as_secs().cast_signed())
749}
750
751impl crate::storage::Vacuumable for StatsDatabase {
752    fn vacuum(&self) -> Result<()> {
753        self.vacuum()
754    }
755
756    fn freelist_count(&self) -> Result<i64> {
757        self.freelist_count()
758    }
759}
760
761fn find_or_create_period(connection: &Connection, now: i64) -> std::result::Result<i64, rusqlite::Error> {
762    let period_start = now.div_euclid(3600).saturating_mul(3600);
763    connection.execute(
764        "INSERT OR IGNORE INTO bandwidth_period(period_start, total_upload, total_download, closed)
765         VALUES (?1, 0, 0, 0)",
766        params![period_start],
767    )?;
768    Ok(period_start)
769}