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    /// Return bandwidth statistics aggregated from transfer events recorded on
362    /// or after `since_ts`. Unlike [`Self::current_stats`], the folder/peer
363    /// breakdowns are computed directly from `bandwidth_events`, so the window
364    /// can be any timestamp (not just a persisted hourly period bucket).
365    ///
366    /// # Errors
367    ///
368    /// Returns an error if the database cannot be queried.
369    pub fn stats_since(&self, since_ts: i64) -> Result<BandwidthStats> {
370        let connection = self
371            .connection
372            .lock()
373            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
374
375        let (total_upload, total_download): (i64, i64) = connection
376            .query_row(
377                "SELECT COALESCE(SUM(CASE WHEN direction='upload' THEN bytes ELSE 0 END), 0),
378                    COALESCE(SUM(CASE WHEN direction='download' THEN bytes ELSE 0 END), 0)
379                 FROM bandwidth_events WHERE timestamp >= ?1",
380                params![since_ts],
381                |row| Ok((row.get(0)?, row.get(1)?)),
382            )
383            .map_err(|error| SyncwebError::operation("failed to query bandwidth totals", error))?;
384
385        let mut per_folder = BTreeMap::new();
386        let mut folder_stmt = connection
387            .prepare(
388                "SELECT folder_namespace,
389                        COALESCE(SUM(CASE WHEN direction='upload' THEN bytes ELSE 0 END), 0),
390                        COALESCE(SUM(CASE WHEN direction='download' THEN bytes ELSE 0 END), 0),
391                        COALESCE(SUM(files), 0)
392                 FROM bandwidth_events
393                 WHERE timestamp >= ?1 AND folder_namespace IS NOT NULL
394                 GROUP BY folder_namespace",
395            )
396            .map_err(|error| SyncwebError::operation("failed to prepare folder stats query", error))?;
397        let folder_rows = folder_stmt
398            .query_map(params![since_ts], |row| {
399                Ok((
400                    row.get::<_, Option<String>>(0)?.unwrap_or_default(),
401                    row.get::<_, i64>(1)?,
402                    row.get::<_, i64>(2)?,
403                    row.get::<_, i64>(3)?,
404                ))
405            })
406            .map_err(|error| SyncwebError::operation("failed to query folder stats", error))?
407            .collect::<std::result::Result<Vec<_>, _>>()
408            .map_err(|error| SyncwebError::operation("failed to read folder stats", error))?;
409        for (ns, upload, download, files) in folder_rows {
410            per_folder.insert(
411                ns,
412                FolderStats {
413                    upload: upload.cast_unsigned(),
414                    download: download.cast_unsigned(),
415                    files_transferred: files.cast_unsigned(),
416                },
417            );
418        }
419        drop(folder_stmt);
420
421        let mut per_peer = BTreeMap::new();
422        let mut peer_stmt = connection
423            .prepare(
424                "SELECT peer,
425                        COALESCE(SUM(CASE WHEN direction='upload' THEN bytes ELSE 0 END), 0),
426                        COALESCE(SUM(CASE WHEN direction='download' THEN bytes ELSE 0 END), 0),
427                        COUNT(*)
428                 FROM bandwidth_events
429                 WHERE timestamp >= ?1 AND peer IS NOT NULL
430                 GROUP BY peer",
431            )
432            .map_err(|error| SyncwebError::operation("failed to prepare peer stats query", error))?;
433        let peer_rows = peer_stmt
434            .query_map(params![since_ts], |row| {
435                Ok((
436                    row.get::<_, String>(0)?,
437                    row.get::<_, i64>(1)?,
438                    row.get::<_, i64>(2)?,
439                    row.get::<_, i64>(3)?,
440                ))
441            })
442            .map_err(|error| SyncwebError::operation("failed to query peer stats", error))?
443            .collect::<std::result::Result<Vec<_>, _>>()
444            .map_err(|error| SyncwebError::operation("failed to read peer stats", error))?;
445        for (peer, upload, download, conns) in peer_rows {
446            per_peer.insert(
447                peer,
448                PeerStats {
449                    upload: upload.cast_unsigned(),
450                    download: download.cast_unsigned(),
451                    connection_count: conns.cast_unsigned(),
452                },
453            );
454        }
455        drop(peer_stmt);
456        drop(connection);
457        Ok(BandwidthStats {
458            total_upload: total_upload.cast_unsigned(),
459            total_download: total_download.cast_unsigned(),
460            per_folder,
461            per_peer,
462            period_start: since_ts.cast_unsigned(),
463        })
464    }
465
466    /// Return network events newer than `after_id`, ordered by id.
467    ///
468    /// # Errors
469    ///
470    /// Returns an error if the database query fails.
471    pub fn network_events_after(&self, after_id: i64, limit: usize) -> Result<Vec<NetworkEventRecord>> {
472        let connection = self
473            .connection
474            .lock()
475            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
476        let mut stmt = connection
477            .prepare(
478                "SELECT id, timestamp, network_id, event_type, peer, details, metadata_json
479                 FROM network_events WHERE id > ?1
480                 ORDER BY id ASC LIMIT ?2",
481            )
482            .map_err(|error| SyncwebError::operation("failed to prepare network events query", error))?;
483        let records = stmt
484            .query_map(params![after_id, limit.cast_signed()], |row| {
485                Ok(NetworkEventRecord {
486                    id: row.get(0)?,
487                    timestamp: row.get(1)?,
488                    network_id: row.get(2)?,
489                    event_type: row.get(3)?,
490                    peer: row.get(4)?,
491                    details: row.get(5)?,
492                    metadata_json: row.get(6)?,
493                })
494            })
495            .map_err(|error| SyncwebError::operation("failed to query network events", error))?
496            .collect::<std::result::Result<Vec<_>, _>>()
497            .map_err(|error| SyncwebError::operation("failed to read network event rows", error))?;
498        drop(stmt);
499        drop(connection);
500        Ok(records)
501    }
502
503    /// Return sync sessions newer than `after_id`, ordered by id.
504    ///
505    /// # Errors
506    ///
507    /// Returns an error if the database query fails.
508    pub fn sync_sessions_after(&self, after_id: i64, limit: usize) -> Result<Vec<SyncSessionRecord>> {
509        let connection = self
510            .connection
511            .lock()
512            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
513        let mut stmt = connection
514            .prepare(
515                "SELECT id, network_id, folder_namespace, started_at, finished_at,
516                 files_transferred, bytes_transferred, errors, status
517                 FROM network_sync_sessions WHERE id > ?1
518                 ORDER BY id ASC LIMIT ?2",
519            )
520            .map_err(|error| SyncwebError::operation("failed to prepare sync sessions query", error))?;
521        let records = stmt
522            .query_map(params![after_id, limit.cast_signed()], |row| {
523                Ok(SyncSessionRecord {
524                    id: row.get(0)?,
525                    network_id: row.get(1)?,
526                    folder_namespace: row.get(2)?,
527                    started_at: row.get(3)?,
528                    finished_at: row.get(4)?,
529                    files_transferred: row.get::<_, i64>(5)?.cast_unsigned(),
530                    bytes_transferred: row.get::<_, i64>(6)?.cast_unsigned(),
531                    errors: row.get::<_, i64>(7)?.cast_unsigned(),
532                    status: row.get(8)?,
533                })
534            })
535            .map_err(|error| SyncwebError::operation("failed to query sync sessions", error))?
536            .collect::<std::result::Result<Vec<_>, _>>()
537            .map_err(|error| SyncwebError::operation("failed to read sync session rows", error))?;
538        drop(stmt);
539        drop(connection);
540        Ok(records)
541    }
542
543    /// Delete all persisted bandwidth counters and transfer events across every
544    /// period, zeroing the totals reported by [`Self::current_stats`].
545    ///
546    /// # Errors
547    ///
548    /// Returns an error if the database operation fails.
549    pub fn reset_bandwidth(&self) -> Result<()> {
550        let connection = self
551            .connection
552            .lock()
553            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
554        connection
555            .execute("DELETE FROM bandwidth_events", [])
556            .map_err(|error| SyncwebError::operation("failed to clear bandwidth events", error))?;
557        connection
558            .execute("DELETE FROM bandwidth_folder", [])
559            .map_err(|error| SyncwebError::operation("failed to clear folder bandwidth", error))?;
560        connection
561            .execute("DELETE FROM bandwidth_peer", [])
562            .map_err(|error| SyncwebError::operation("failed to clear peer bandwidth", error))?;
563        connection
564            .execute("DELETE FROM bandwidth_period", [])
565            .map_err(|error| SyncwebError::operation("failed to clear bandwidth periods", error))?;
566        drop(connection);
567        Ok(())
568    }
569
570    /// Append a daemon log entry.
571    ///
572    /// # Errors
573    ///
574    /// Returns an error if the database write fails.
575    pub fn append_log(&self, level: &str, module: Option<&str>, message: &str) -> Result<()> {
576        let connection = self
577            .connection
578            .lock()
579            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
580        let now = current_timestamp();
581        connection
582            .execute(
583                "INSERT INTO daemon_log(timestamp, level, module, message) VALUES (?1, ?2, ?3, ?4)",
584                params![now, level, module, message],
585            )
586            .map_err(|error| SyncwebError::operation("failed to append log entry", error))?;
587        drop(connection);
588        Ok(())
589    }
590
591    /// Delete bandwidth events older than the given duration.
592    ///
593    /// # Errors
594    ///
595    /// Returns an error if the database operation fails.
596    pub fn purge_old_bandwidth(&self, older_than: Duration) -> Result<usize> {
597        let connection = self
598            .connection
599            .lock()
600            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
601        let cutoff = current_timestamp().saturating_sub(older_than.as_secs().cast_signed());
602        let deleted = connection
603            .execute("DELETE FROM bandwidth_events WHERE timestamp < ?1", params![cutoff])
604            .map_err(|error| SyncwebError::operation("failed to purge old bandwidth events", error))?;
605        drop(connection);
606        Ok(deleted)
607    }
608
609    /// Record a network event.
610    ///
611    /// # Errors
612    ///
613    /// Returns an error if the database write fails.
614    pub fn record_network_event(
615        &self,
616        network_id: &str,
617        event_type: &str,
618        peer: Option<&str>,
619        details: Option<&str>,
620        metadata_json: Option<&str>,
621    ) -> Result<()> {
622        let connection = self
623            .connection
624            .lock()
625            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
626        let now = current_timestamp();
627        connection
628            .execute(
629                "INSERT INTO network_events(timestamp, network_id, event_type, peer, details, metadata_json)
630             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
631                params![now, network_id, event_type, peer, details, metadata_json],
632            )
633            .map_err(|error| SyncwebError::operation("failed to record network event", error))?;
634        drop(connection);
635        Ok(())
636    }
637
638    /// Record a sync session start and return its session ID.
639    ///
640    /// # Errors
641    ///
642    /// Returns an error if the database write fails.
643    pub fn record_sync_session_start(&self, network_id: &str, folder_namespace: &str) -> Result<i64> {
644        let connection = self
645            .connection
646            .lock()
647            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
648        let now = current_timestamp();
649        connection
650            .execute(
651                "INSERT INTO network_sync_sessions(network_id, folder_namespace, started_at)
652             VALUES (?1, ?2, ?3)",
653                params![network_id, folder_namespace, now],
654            )
655            .map_err(|error| SyncwebError::operation("failed to record sync session start", error))?;
656        let session_id = connection.last_insert_rowid();
657        drop(connection);
658        Ok(session_id)
659    }
660
661    /// Record a sync session finish.
662    ///
663    /// # Errors
664    ///
665    /// Returns an error if the database write fails.
666    pub fn record_sync_session_finish(
667        &self,
668        session_id: i64,
669        files: u64,
670        bytes: u64,
671        errors: u64,
672        status: &str,
673    ) -> Result<()> {
674        let connection = self
675            .connection
676            .lock()
677            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
678        let now = current_timestamp();
679        connection
680            .execute(
681                "UPDATE network_sync_sessions SET finished_at = ?1, files_transferred = ?2,
682                 bytes_transferred = ?3, errors = ?4, status = ?5 WHERE id = ?6",
683                params![
684                    now,
685                    files.cast_signed(),
686                    bytes.cast_signed(),
687                    errors.cast_signed(),
688                    status,
689                    session_id
690                ],
691            )
692            .map_err(|error| SyncwebError::operation("failed to record sync session finish", error))?;
693        drop(connection);
694        Ok(())
695    }
696
697    /// Record a relay health check.
698    ///
699    /// # Errors
700    ///
701    /// Returns an error if the database write fails.
702    pub fn record_relay_check(
703        &self,
704        relay_url: &str,
705        connected: bool,
706        latency_ms: Option<i64>,
707        error_message: Option<&str>,
708    ) -> Result<()> {
709        let connection = self
710            .connection
711            .lock()
712            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
713        let now = current_timestamp();
714        connection
715            .execute(
716                "INSERT INTO relay_health(relay_url, checked_at, connected, latency_ms, error_message)
717             VALUES (?1, ?2, ?3, ?4, ?5)",
718                params![relay_url, now, i64::from(connected), latency_ms, error_message],
719            )
720            .map_err(|error| SyncwebError::operation("failed to record relay check", error))?;
721        drop(connection);
722        Ok(())
723    }
724
725    /// Query recent network events for a given network.
726    ///
727    /// # Errors
728    ///
729    /// Returns an error if the database query fails.
730    pub fn recent_network_events(&self, network_id: &str, limit: usize) -> Result<Vec<NetworkEventRecord>> {
731        let connection = self
732            .connection
733            .lock()
734            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
735        let mut stmt = connection
736            .prepare(
737                "SELECT id, timestamp, network_id, event_type, peer, details, metadata_json
738             FROM network_events WHERE network_id = ?1
739             ORDER BY timestamp DESC LIMIT ?2",
740            )
741            .map_err(|error| SyncwebError::operation("failed to prepare network events query", error))?;
742        let records = stmt
743            .query_map(params![network_id, limit.cast_signed()], |row| {
744                Ok(NetworkEventRecord {
745                    id: row.get(0)?,
746                    timestamp: row.get(1)?,
747                    network_id: row.get(2)?,
748                    event_type: row.get(3)?,
749                    peer: row.get(4)?,
750                    details: row.get(5)?,
751                    metadata_json: row.get(6)?,
752                })
753            })
754            .map_err(|error| SyncwebError::operation("failed to query network events", error))?
755            .collect::<std::result::Result<Vec<_>, _>>()
756            .map_err(|error| SyncwebError::operation("failed to read network event rows", error))?;
757        drop(stmt);
758        drop(connection);
759        Ok(records)
760    }
761
762    /// Query recent sync sessions for a given network.
763    ///
764    /// # Errors
765    ///
766    /// Returns an error if the database query fails.
767    pub fn recent_sync_sessions(&self, network_id: &str, limit: usize) -> Result<Vec<SyncSessionRecord>> {
768        let connection = self
769            .connection
770            .lock()
771            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
772        let mut stmt = connection
773            .prepare(
774                "SELECT id, network_id, folder_namespace, started_at, finished_at,
775                 files_transferred, bytes_transferred, errors, status
776             FROM network_sync_sessions WHERE network_id = ?1
777             ORDER BY started_at DESC LIMIT ?2",
778            )
779            .map_err(|error| SyncwebError::operation("failed to prepare sync sessions query", error))?;
780        let records = stmt
781            .query_map(params![network_id, limit.cast_signed()], |row| {
782                Ok(SyncSessionRecord {
783                    id: row.get(0)?,
784                    network_id: row.get(1)?,
785                    folder_namespace: row.get(2)?,
786                    started_at: row.get(3)?,
787                    finished_at: row.get(4)?,
788                    files_transferred: row.get::<_, i64>(5)?.cast_unsigned(),
789                    bytes_transferred: row.get::<_, i64>(6)?.cast_unsigned(),
790                    errors: row.get::<_, i64>(7)?.cast_unsigned(),
791                    status: row.get(8)?,
792                })
793            })
794            .map_err(|error| SyncwebError::operation("failed to query sync sessions", error))?
795            .collect::<std::result::Result<Vec<_>, _>>()
796            .map_err(|error| SyncwebError::operation("failed to read sync session rows", error))?;
797        drop(stmt);
798        drop(connection);
799        Ok(records)
800    }
801
802    /// Calculate relay uptime as a ratio (0.0-1.0) over the given time window.
803    ///
804    /// # Errors
805    ///
806    /// Returns an error if the database query fails.
807    pub fn relay_uptime(&self, relay_url: &str, window_secs: i64) -> Result<f64> {
808        let connection = self
809            .connection
810            .lock()
811            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
812        let cutoff = current_timestamp().saturating_sub(window_secs);
813        let (total, connected): (i64, i64) = connection
814            .query_row(
815                "SELECT COUNT(*), SUM(connected) FROM relay_health
816             WHERE relay_url = ?1 AND checked_at >= ?2",
817                params![relay_url, cutoff],
818                |row| Ok((row.get(0)?, row.get::<_, Option<i64>>(1)?.unwrap_or(0))),
819            )
820            .map_err(|error| SyncwebError::operation("failed to query relay uptime", error))?;
821        drop(connection);
822        Ok(if total > 0 {
823            f64::from(i32::try_from(connected).unwrap_or(0))
824                .mul_add(f64::from(i32::try_from(total).unwrap_or(1)).recip(), 0.0)
825        } else {
826            1.0
827        })
828    }
829
830    /// Run VACUUM to reclaim space.
831    ///
832    /// # Errors
833    ///
834    /// Returns an error if the database operation fails.
835    pub fn vacuum(&self) -> Result<()> {
836        let connection = self
837            .connection
838            .lock()
839            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
840        connection
841            .execute_batch("VACUUM")
842            .map_err(|error| SyncwebError::operation("vacuum failed", error))?;
843        drop(connection);
844        Ok(())
845    }
846
847    /// Return estimated database size in bytes.
848    ///
849    /// # Errors
850    ///
851    /// Returns an error if the file metadata cannot be read.
852    pub fn size_on_disk(&self) -> Result<u64> {
853        let connection = self
854            .connection
855            .lock()
856            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
857        let db_path: String = connection
858            .query_row("PRAGMA database_list", [], |row| row.get(2))
859            .map_err(|error| SyncwebError::operation("failed to query database path", error))?;
860        drop(connection);
861        std::fs::metadata(&db_path)
862            .map(|meta| meta.len())
863            .map_err(|error| SyncwebError::operation("failed to get database file size", error))
864    }
865
866    /// Return the freelist page count (indicates whether VACUUM would help).
867    ///
868    /// # Errors
869    ///
870    /// Returns an error if the database operation fails.
871    pub fn freelist_count(&self) -> Result<i64> {
872        let connection = self
873            .connection
874            .lock()
875            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
876        let count: i64 = connection
877            .pragma_query_value(None, "freelist_count", |row| row.get(0))
878            .map_err(|error| SyncwebError::operation("failed to query freelist count", error))?;
879        drop(connection);
880        Ok(count)
881    }
882
883    /// Run `integrity_check` PRAGMA. Returns list of errors (empty = healthy).
884    ///
885    /// # Errors
886    ///
887    /// Returns an error if the database operation fails.
888    pub fn check_integrity(&self) -> Result<Vec<String>> {
889        let connection = self
890            .connection
891            .lock()
892            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
893        let mut stmt = connection
894            .prepare("PRAGMA integrity_check")
895            .map_err(|error| SyncwebError::operation("failed to prepare integrity check", error))?;
896        let errors: Vec<String> = stmt
897            .query_map([], |row| row.get::<_, String>(0))
898            .map_err(|error| SyncwebError::operation("failed to run integrity check", error))?
899            .filter_map(std::result::Result::ok)
900            .filter(|s| s != "ok")
901            .collect();
902        drop(stmt);
903        drop(connection);
904        Ok(errors)
905    }
906
907    /// Delete daemon log entries older than the given duration.
908    ///
909    /// # Errors
910    ///
911    /// Returns an error if the database operation fails.
912    pub fn purge_old_logs(&self, older_than: Duration) -> Result<usize> {
913        let connection = self
914            .connection
915            .lock()
916            .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
917        let cutoff = current_timestamp().saturating_sub(older_than.as_secs().cast_signed());
918        let deleted = connection
919            .execute("DELETE FROM daemon_log WHERE timestamp < ?1", params![cutoff])
920            .map_err(|error| SyncwebError::operation("failed to purge old log entries", error))?;
921        drop(connection);
922        Ok(deleted)
923    }
924}
925
926/// A record from the `network_events` table.
927#[derive(Clone, Debug)]
928#[non_exhaustive]
929pub struct NetworkEventRecord {
930    pub id: i64,
931    pub timestamp: i64,
932    pub network_id: String,
933    pub event_type: String,
934    pub peer: Option<String>,
935    pub details: Option<String>,
936    pub metadata_json: Option<String>,
937}
938
939/// A record from the `network_sync_sessions` table.
940#[derive(Clone, Debug)]
941#[non_exhaustive]
942pub struct SyncSessionRecord {
943    pub id: i64,
944    pub network_id: String,
945    pub folder_namespace: String,
946    pub started_at: i64,
947    pub finished_at: Option<i64>,
948    pub files_transferred: u64,
949    pub bytes_transferred: u64,
950    pub errors: u64,
951    pub status: String,
952}
953
954fn current_timestamp() -> i64 {
955    std::time::SystemTime::now()
956        .duration_since(std::time::UNIX_EPOCH)
957        .map_or(0, |d| d.as_secs().cast_signed())
958}
959
960impl crate::storage::Vacuumable for StatsDatabase {
961    fn vacuum(&self) -> Result<()> {
962        self.vacuum()
963    }
964
965    fn freelist_count(&self) -> Result<i64> {
966        self.freelist_count()
967    }
968}
969
970fn find_or_create_period(connection: &Connection, now: i64) -> std::result::Result<i64, rusqlite::Error> {
971    let period_start = now.div_euclid(3600).saturating_mul(3600);
972    connection.execute(
973        "INSERT OR IGNORE INTO bandwidth_period(period_start, total_upload, total_download, closed)
974         VALUES (?1, 0, 0, 0)",
975        params![period_start],
976    )?;
977    Ok(period_start)
978}