Skip to main content

syncweb_core/storage/
node_db.rs

1use std::{
2    collections::{BTreeMap, HashMap, HashSet},
3    path::{Path, PathBuf},
4    str::FromStr,
5    sync::{Arc, Mutex},
6};
7
8use iroh::PublicKey;
9use iroh_docs::NamespaceId;
10use rusqlite::{Connection, OptionalExtension, params};
11use serde::{Deserialize, Serialize};
12use tracing;
13use uuid::Uuid;
14
15use crate::{
16    Result, SyncwebError,
17    daemon::{
18        BandwidthSnapshot, DaemonState, DaemonStatus, DaemonStatusReport, FolderStatusReport, ScheduleStatus,
19        current_timestamp,
20    },
21    filter::{FilterAction, FilterEngine, FilterEntry, FilterRule},
22    folder::{CollectionState, InstalledCollection},
23    net::network::{Network, NetworkId, parse_public_key},
24    storage::config::Config as AppConfig,
25};
26
27/// Parameters for upserting a sync entry progress record.
28#[derive(Debug)]
29#[non_exhaustive]
30pub struct SyncEntryParams<'a> {
31    pub namespace_id: &'a str,
32    pub session_id: &'a str,
33    pub entry_key: &'a [u8],
34    pub hash: &'a [u8],
35    pub size: u64,
36    pub status: &'a str,
37    pub retries: u32,
38    pub error_message: Option<&'a str>,
39}
40
41#[derive(Clone, Debug, Deserialize, Serialize)]
42#[non_exhaustive]
43pub struct StorageRootRecord {
44    pub id: String,
45    pub path: PathBuf,
46    pub min_free: u64,
47    pub enabled: bool,
48}
49
50impl StorageRootRecord {
51    /// Create a storage root record.
52    #[must_use]
53    pub fn new(id: impl Into<String>, path: impl Into<PathBuf>, min_free: u64, enabled: bool) -> Self {
54        Self {
55            id: id.into(),
56            path: path.into(),
57            min_free,
58            enabled,
59        }
60    }
61}
62
63#[derive(Clone, Debug, Deserialize, Serialize)]
64#[non_exhaustive]
65pub struct TransferJobRecord {
66    pub id: String,
67    pub namespace_id: String,
68    pub entry_key: Vec<u8>,
69    pub hash: [u8; 32],
70    pub size: u64,
71    pub root_id: Option<String>,
72    pub destination: Option<PathBuf>,
73    pub state: String,
74    pub bytes_transferred: u64,
75    pub peer_count: u64,
76    pub eta_seconds: Option<u64>,
77    pub retries: u32,
78    pub error_message: Option<String>,
79    pub created_at: u64,
80    pub updated_at: u64,
81}
82
83#[derive(Debug)]
84#[non_exhaustive]
85pub struct NewTransferJob<'a> {
86    pub namespace_id: &'a str,
87    pub entry_key: &'a [u8],
88    pub hash: &'a [u8; 32],
89    pub size: u64,
90    pub root_id: Option<&'a str>,
91    pub destination: Option<&'a Path>,
92}
93
94impl<'a> NewTransferJob<'a> {
95    /// Create a transfer job insertion record.
96    #[must_use]
97    pub const fn new(
98        namespace_id: &'a str,
99        entry_key: &'a [u8],
100        hash: &'a [u8; 32],
101        size: u64,
102        root_id: Option<&'a str>,
103        destination: Option<&'a Path>,
104    ) -> Self {
105        Self {
106            namespace_id,
107            entry_key,
108            hash,
109            size,
110            root_id,
111            destination,
112        }
113    }
114}
115
116fn transfer_job_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TransferJobRecord> {
117    let hash_bytes: Vec<u8> = row.get(3)?;
118    let hash = hash_bytes.try_into().map_err(|bytes: Vec<u8>| {
119        rusqlite::Error::FromSqlConversionFailure(
120            3,
121            rusqlite::types::Type::Blob,
122            format!("expected 32-byte hash, got {} bytes", bytes.len()).into(),
123        )
124    })?;
125    let size: i64 = row.get(4)?;
126    let bytes_transferred: i64 = row.get(8)?;
127    let peer_count: i64 = row.get(9)?;
128    let eta_seconds: Option<i64> = row.get(10)?;
129    let retries: i64 = row.get(11)?;
130    let created_at: i64 = row.get(13)?;
131    let updated_at: i64 = row.get(14)?;
132    let retry_count = u32::try_from(retries).map_err(|error| {
133        rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Integer, Box::new(error))
134    })?;
135    Ok(TransferJobRecord {
136        id: row.get(0)?,
137        namespace_id: row.get(1)?,
138        entry_key: row.get(2)?,
139        hash,
140        size: size.cast_unsigned(),
141        root_id: row.get(5)?,
142        destination: row.get::<_, Option<String>>(6)?.map(PathBuf::from),
143        state: row.get(7)?,
144        bytes_transferred: bytes_transferred.cast_unsigned(),
145        peer_count: peer_count.cast_unsigned(),
146        eta_seconds: eta_seconds.map(i64::cast_unsigned),
147        retries: retry_count,
148        error_message: row.get(12)?,
149        created_at: created_at.cast_unsigned(),
150        updated_at: updated_at.cast_unsigned(),
151    })
152}
153
154#[derive(Clone, Debug)]
155pub struct NodeDatabase {
156    connection: Arc<Mutex<Connection>>,
157}
158
159impl NodeDatabase {
160    /// Open (or create) the node database.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if the database cannot be opened or initialized.
165    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
166        let db_path = path.as_ref();
167        if let Some(parent) = db_path.parent() {
168            std::fs::create_dir_all(parent)
169                .map_err(|error| SyncwebError::operation("failed to create node db directory", error))?;
170        }
171        let connection = Connection::open(db_path)
172            .map_err(|error| SyncwebError::operation("failed to open node database", error))?;
173        let db = Self {
174            connection: Arc::new(Mutex::new(connection)),
175        };
176        db.init_schema()?;
177        Ok(db)
178    }
179
180    const CREATE_SCHEMA_SQL: &'static str =
181        "CREATE TABLE IF NOT EXISTS schema_version (
182            key TEXT PRIMARY KEY,
183            value TEXT NOT NULL
184        );
185        CREATE TABLE IF NOT EXISTS daemon_lifecycle (
186            id INTEGER PRIMARY KEY CHECK(id = 1),
187            pid INTEGER NOT NULL,
188            node_id TEXT NOT NULL,
189            started_at INTEGER NOT NULL,
190            status TEXT NOT NULL CHECK(status IN ('starting','running','stopping','stopped')),
191            data_dir TEXT NOT NULL,
192            updated_at INTEGER NOT NULL
193        );
194        CREATE TABLE IF NOT EXISTS daemon_status (
195            id INTEGER PRIMARY KEY CHECK(id = 1),
196            pid INTEGER NOT NULL,
197            node_id TEXT NOT NULL,
198            started_at INTEGER NOT NULL,
199            uptime_seconds INTEGER NOT NULL,
200            upload_total INTEGER NOT NULL DEFAULT 0,
201            download_total INTEGER NOT NULL DEFAULT 0,
202            upload_rate INTEGER NOT NULL DEFAULT 0,
203            download_rate INTEGER NOT NULL DEFAULT 0,
204            has_schedule INTEGER NOT NULL DEFAULT 0,
205            in_active_window INTEGER NOT NULL DEFAULT 0,
206            next_window_start INTEGER,
207            rayon_threads INTEGER NOT NULL,
208            updated_at INTEGER NOT NULL
209        );
210        CREATE TABLE IF NOT EXISTS folder_status_reports (
211            namespace_id TEXT NOT NULL,
212            path TEXT NOT NULL,
213            session_active INTEGER NOT NULL DEFAULT 0,
214            last_sync_at INTEGER,
215            entries_synced INTEGER NOT NULL DEFAULT 0,
216            errors_json TEXT NOT NULL DEFAULT '[]',
217            updated_at INTEGER NOT NULL,
218            PRIMARY KEY(namespace_id)
219        );
220        CREATE TABLE IF NOT EXISTS folder_mounts (
221            namespace_id TEXT PRIMARY KEY,
222            path TEXT NOT NULL,
223            updated_at INTEGER NOT NULL
224        );
225        CREATE TABLE IF NOT EXISTS networks (
226            id TEXT PRIMARY KEY,
227            name TEXT NOT NULL UNIQUE,
228            label TEXT NOT NULL DEFAULT '',
229            owner TEXT NOT NULL,
230            shared_secret BLOB,
231            created_at INTEGER NOT NULL
232        );
233        CREATE TABLE IF NOT EXISTS network_members (
234            network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
235            member TEXT NOT NULL,
236            PRIMARY KEY(network_id, member)
237        );
238        CREATE TABLE IF NOT EXISTS network_folders (
239            network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
240            namespace_id TEXT NOT NULL,
241            PRIMARY KEY(network_id, namespace_id)
242        );
243        CREATE TABLE IF NOT EXISTS installed_collections (
244            collection_id TEXT PRIMARY KEY,
245            manifest_hash TEXT NOT NULL,
246            current_version TEXT NOT NULL,
247            installed_at INTEGER NOT NULL
248        );
249        CREATE TABLE IF NOT EXISTS collection_versions (
250            collection_id TEXT NOT NULL REFERENCES installed_collections(collection_id) ON DELETE CASCADE,
251            version TEXT NOT NULL,
252            install_path TEXT NOT NULL,
253            PRIMARY KEY(collection_id, version)
254        );
255        CREATE TABLE IF NOT EXISTS app_config (
256            key TEXT PRIMARY KEY,
257            value TEXT NOT NULL,
258            updated_at INTEGER NOT NULL
259        );
260        CREATE TABLE IF NOT EXISTS filter_rules (
261            id INTEGER PRIMARY KEY AUTOINCREMENT,
262            namespace_id TEXT,
263            rule_type TEXT NOT NULL,
264            pattern TEXT NOT NULL,
265            priority INTEGER NOT NULL DEFAULT 0,
266            updated_at INTEGER NOT NULL
267        );
268        CREATE TABLE IF NOT EXISTS public_subscriptions (
269            hash TEXT PRIMARY KEY,
270            size INTEGER NOT NULL,
271            subscribed_at INTEGER NOT NULL
272        );
273        CREATE TABLE IF NOT EXISTS shares (
274            namespace_id TEXT NOT NULL,
275            access TEXT NOT NULL CHECK(access IN ('read','write')),
276            ticket TEXT NOT NULL,
277            created_at INTEGER NOT NULL,
278            PRIMARY KEY(namespace_id, access)
279        );
280        CREATE TABLE IF NOT EXISTS workspace_manifests (
281            source_path TEXT NOT NULL,
282            manifest_bytes BLOB NOT NULL,
283            collection_id TEXT NOT NULL,
284            updated_at INTEGER NOT NULL,
285            PRIMARY KEY(source_path)
286        );
287        CREATE TABLE IF NOT EXISTS blob_folders (
288            content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
289            namespace_id TEXT NOT NULL,
290            entry_key BLOB NOT NULL,
291            added_at INTEGER NOT NULL,
292            PRIMARY KEY(content_hash, namespace_id, entry_key)
293        );
294        CREATE INDEX IF NOT EXISTS idx_blob_folders_hash ON blob_folders(content_hash);
295        CREATE TABLE IF NOT EXISTS sync_checkpoints (
296            namespace_id TEXT NOT NULL,
297            session_id TEXT NOT NULL,
298            total_entries INTEGER NOT NULL DEFAULT 0,
299            processed_entries INTEGER NOT NULL DEFAULT 0,
300            failed_entries INTEGER NOT NULL DEFAULT 0,
301            bytes_total INTEGER,
302            bytes_transferred INTEGER NOT NULL DEFAULT 0,
303            started_at INTEGER NOT NULL,
304            last_updated_at INTEGER NOT NULL,
305            status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','completed','failed','cancelled')),
306            PRIMARY KEY(namespace_id, session_id)
307        );
308        CREATE TABLE IF NOT EXISTS sync_entry_progress (
309            namespace_id TEXT NOT NULL,
310            session_id TEXT NOT NULL,
311            entry_key BLOB NOT NULL,
312            hash BLOB NOT NULL,
313            size INTEGER NOT NULL,
314            status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','downloading','completed','failed','skipped')),
315            retries INTEGER NOT NULL DEFAULT 0,
316            error_message TEXT,
317            updated_at INTEGER NOT NULL,
318            PRIMARY KEY(namespace_id, session_id, entry_key),
319            FOREIGN KEY(namespace_id, session_id) REFERENCES sync_checkpoints(namespace_id, session_id) ON DELETE CASCADE
320        );
321        CREATE TABLE IF NOT EXISTS storage_roots (
322            id TEXT PRIMARY KEY,
323            path TEXT NOT NULL UNIQUE,
324            min_free INTEGER NOT NULL DEFAULT 0 CHECK(min_free >= 0),
325            enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
326            created_at INTEGER NOT NULL,
327            updated_at INTEGER NOT NULL
328        );
329        CREATE TABLE IF NOT EXISTS transfer_jobs (
330            id TEXT PRIMARY KEY,
331            namespace_id TEXT NOT NULL,
332            entry_key BLOB NOT NULL,
333            hash BLOB NOT NULL CHECK(length(hash) = 32),
334            size INTEGER NOT NULL CHECK(size >= 0),
335            root_id TEXT,
336            destination TEXT,
337            state TEXT NOT NULL DEFAULT 'queued'
338                CHECK(state IN ('queued','fetching','materializing','paused','completed','failed','cancelled')),
339            bytes_transferred INTEGER NOT NULL DEFAULT 0 CHECK(bytes_transferred >= 0),
340            peer_count INTEGER NOT NULL DEFAULT 0 CHECK(peer_count >= 0),
341            eta_seconds INTEGER,
342            retries INTEGER NOT NULL DEFAULT 0 CHECK(retries >= 0),
343            error_message TEXT,
344            created_at INTEGER NOT NULL,
345            updated_at INTEGER NOT NULL,
346            UNIQUE(namespace_id, entry_key, root_id),
347            FOREIGN KEY(root_id) REFERENCES storage_roots(id) ON DELETE SET NULL
348        );";
349
350    /// Returns the SQL statements to create the database schema.
351    const fn create_schema_sql() -> &'static str {
352        Self::CREATE_SCHEMA_SQL
353    }
354
355    /// Initialize the database schema, creating tables and indexes if they don't exist.
356    ///
357    /// # Errors
358    ///
359    /// Returns an error if the database mutex is poisoned or if a SQL operation fails.
360    pub fn init_schema(&self) -> Result<()> {
361        let connection = self
362            .connection
363            .lock()
364            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
365        connection
366            .busy_timeout(std::time::Duration::from_secs(5))
367            .map_err(|error| SyncwebError::operation("failed to configure node database", error))?;
368        connection
369            .pragma_update(None, "journal_mode", "WAL")
370            .map_err(|error| SyncwebError::operation("failed to set WAL mode", error))?;
371        connection
372            .pragma_update(None, "foreign_keys", "ON")
373            .map_err(|error| SyncwebError::operation("failed to enable foreign keys", error))?;
374        connection
375            .execute_batch(Self::create_schema_sql())
376            .map_err(|error| SyncwebError::operation("failed to initialize node database schema", error))?;
377        if let Err(error) = connection.execute_batch("ALTER TABLE networks ADD COLUMN doc_ticket TEXT") {
378            tracing::debug!(%error, "migration: add doc_ticket (may already exist)");
379        }
380        if let Err(error) = connection.execute_batch(
381            "CREATE INDEX IF NOT EXISTS idx_transfer_jobs_namespace ON transfer_jobs(namespace_id);
382             CREATE INDEX IF NOT EXISTS idx_transfer_jobs_state ON transfer_jobs(state);
383             CREATE INDEX IF NOT EXISTS idx_transfer_jobs_root ON transfer_jobs(root_id);",
384        ) {
385            tracing::debug!(%error, "migration: add transfer job indexes (may already exist)");
386        }
387
388        connection
389            .execute(
390                "INSERT INTO schema_version(key, value) VALUES ('version', '1')
391             ON CONFLICT(key) DO NOTHING",
392                [],
393            )
394            .map_err(|error| SyncwebError::operation("failed to seed schema version", error))?;
395        drop(connection);
396        Ok(())
397    }
398
399    /// Persist the daemon lifecycle state.
400    ///
401    /// # Errors
402    ///
403    /// Returns an error if the database cannot be written.
404    pub fn save_lifecycle(&self, state: &DaemonState) -> Result<()> {
405        let connection = self
406            .connection
407            .lock()
408            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
409        let status = match state.status {
410            DaemonStatus::Starting => "starting",
411            DaemonStatus::Running => "running",
412            DaemonStatus::Stopping => "stopping",
413            DaemonStatus::Stopped => "stopped",
414        };
415        let now = current_timestamp();
416        connection
417            .execute(
418                "INSERT INTO daemon_lifecycle(id, pid, node_id, started_at, status, data_dir, updated_at)
419             VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)
420             ON CONFLICT(id) DO UPDATE SET
421                pid = excluded.pid,
422                node_id = excluded.node_id,
423                started_at = excluded.started_at,
424                status = excluded.status,
425                data_dir = excluded.data_dir,
426                updated_at = excluded.updated_at",
427                params![
428                    i64::from(state.pid),
429                    state.node_id,
430                    state.started_at.cast_signed(),
431                    status,
432                    state.data_dir.to_string_lossy().as_ref(),
433                    now.cast_signed(),
434                ],
435            )
436            .map_err(|error| SyncwebError::operation("failed to save daemon lifecycle", error))?;
437        drop(connection);
438        Ok(())
439    }
440
441    /// Load the daemon lifecycle state, returning `None` if absent.
442    ///
443    /// # Errors
444    ///
445    /// Returns an error if the database cannot be read.
446    pub fn load_lifecycle(&self) -> Result<Option<DaemonState>> {
447        let connection = self
448            .connection
449            .lock()
450            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
451        let result = connection
452            .query_row(
453                "SELECT pid, node_id, started_at, status, data_dir FROM daemon_lifecycle WHERE id = 1",
454                [],
455                |row| {
456                    let pid: i64 = row.get(0)?;
457                    let node_id: String = row.get(1)?;
458                    let started_at: i64 = row.get(2)?;
459                    let status_str: String = row.get(3)?;
460                    let data_dir: String = row.get(4)?;
461                    let status = match status_str.as_str() {
462                        "starting" => DaemonStatus::Starting,
463                        "running" => DaemonStatus::Running,
464                        "stopping" => DaemonStatus::Stopping,
465                        "stopped" => DaemonStatus::Stopped,
466                        _ => {
467                            return Err(rusqlite::Error::InvalidColumnType(
468                                3,
469                                "status".to_string(),
470                                rusqlite::types::Type::Text,
471                            ));
472                        }
473                    };
474                    Ok(DaemonState::new(
475                        u32::try_from(pid).unwrap_or_default(),
476                        node_id,
477                        started_at.cast_unsigned(),
478                        data_dir,
479                        status,
480                    ))
481                },
482            )
483            .optional()
484            .map_err(|error| SyncwebError::operation("failed to load daemon lifecycle", error))?;
485        drop(connection);
486        Ok(result)
487    }
488
489    /// Remove the daemon lifecycle state.
490    ///
491    /// # Errors
492    ///
493    /// Returns an error if the database cannot be written.
494    pub fn remove_lifecycle(&self) -> Result<()> {
495        let connection = self
496            .connection
497            .lock()
498            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
499        connection
500            .execute("DELETE FROM daemon_lifecycle WHERE id = 1", [])
501            .map_err(|error| SyncwebError::operation("failed to remove daemon lifecycle", error))?;
502        drop(connection);
503        Ok(())
504    }
505
506    /// Persist the daemon status report.
507    ///
508    /// # Errors
509    ///
510    /// Returns an error if the database cannot be written.
511    pub fn save_status(&self, report: &DaemonStatusReport) -> Result<()> {
512        let connection = self
513            .connection
514            .lock()
515            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
516        let now = current_timestamp().cast_signed();
517        let has_schedule = report.schedule.is_some();
518        connection
519            .execute(
520                "INSERT INTO daemon_status(id, pid, node_id, started_at, uptime_seconds,
521                upload_total, download_total, upload_rate, download_rate,
522                has_schedule, in_active_window, next_window_start, rayon_threads, updated_at)
523             VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
524             ON CONFLICT(id) DO UPDATE SET
525                pid = excluded.pid,
526                node_id = excluded.node_id,
527                started_at = excluded.started_at,
528                uptime_seconds = excluded.uptime_seconds,
529                upload_total = excluded.upload_total,
530                download_total = excluded.download_total,
531                upload_rate = excluded.upload_rate,
532                download_rate = excluded.download_rate,
533                has_schedule = excluded.has_schedule,
534                in_active_window = excluded.in_active_window,
535                next_window_start = excluded.next_window_start,
536                rayon_threads = excluded.rayon_threads,
537                updated_at = excluded.updated_at",
538                params![
539                    i64::from(report.pid),
540                    report.node_id,
541                    report.started_at.cast_signed(),
542                    report.uptime_seconds.cast_signed(),
543                    report.bandwidth.upload_total.cast_signed(),
544                    report.bandwidth.download_total.cast_signed(),
545                    report.bandwidth.upload_rate.cast_signed(),
546                    report.bandwidth.download_rate.cast_signed(),
547                    i64::from(has_schedule),
548                    report.schedule.map_or(0, |s| i64::from(s.in_active_window)),
549                    report.schedule.and_then(|s| s.next_window_start.map(u64::cast_signed)),
550                    i64::try_from(report.rayon_threads).unwrap_or_default(),
551                    now,
552                ],
553            )
554            .map_err(|error| SyncwebError::operation("failed to save daemon status", error))?;
555
556        for folder in &report.folders {
557            let errors_json = serde_json::to_string(&folder.errors).unwrap_or_else(|_| "[]".to_string());
558            connection
559                .execute(
560                    "INSERT INTO folder_status_reports(namespace_id, path, session_active, last_sync_at,
561                    entries_synced, errors_json, updated_at)
562                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
563                 ON CONFLICT(namespace_id) DO UPDATE SET
564                    path = excluded.path,
565                    session_active = excluded.session_active,
566                    last_sync_at = excluded.last_sync_at,
567                    entries_synced = excluded.entries_synced,
568                    errors_json = excluded.errors_json,
569                    updated_at = excluded.updated_at",
570                    params![
571                        folder.namespace,
572                        folder.path.to_string_lossy().as_ref(),
573                        i64::from(folder.session_active),
574                        folder.last_sync_at.map(u64::cast_signed),
575                        folder.entries_synced.cast_signed(),
576                        errors_json,
577                        now,
578                    ],
579                )
580                .map_err(|error| SyncwebError::operation("failed to save folder status", error))?;
581        }
582        drop(connection);
583        Ok(())
584    }
585
586    /// Load the daemon status report, returning `None` if absent.
587    ///
588    /// # Errors
589    ///
590    /// Returns an error if the database cannot be read.
591    pub fn load_status(&self) -> Result<Option<DaemonStatusReport>> {
592        let connection = self
593            .connection
594            .lock()
595            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
596        let result: Option<DaemonStatusReport> = connection
597            .query_row(
598                "SELECT pid, node_id, started_at, uptime_seconds,
599                upload_total, download_total, upload_rate, download_rate,
600                has_schedule, in_active_window, next_window_start, rayon_threads
601             FROM daemon_status WHERE id = 1",
602                [],
603                |row| {
604                    let pid: i64 = row.get(0)?;
605                    let node_id: String = row.get(1)?;
606                    let started_at: i64 = row.get(2)?;
607                    let uptime_seconds: i64 = row.get(3)?;
608                    let upload_total: i64 = row.get(4)?;
609                    let download_total: i64 = row.get(5)?;
610                    let upload_rate: i64 = row.get(6)?;
611                    let download_rate: i64 = row.get(7)?;
612                    let has_schedule: i64 = row.get(8)?;
613                    let in_active_window: i64 = row.get(9)?;
614                    let next_window_start: Option<i64> = row.get(10)?;
615                    let rayon_threads: i64 = row.get(11)?;
616
617                    let schedule = (has_schedule != 0).then(|| ScheduleStatus {
618                        in_active_window: in_active_window != 0,
619                        next_window_start: next_window_start.map(i64::cast_unsigned),
620                    });
621
622                    Ok(DaemonStatusReport {
623                        pid: u32::try_from(pid).unwrap_or_default(),
624                        node_id,
625                        started_at: started_at.cast_unsigned(),
626                        uptime_seconds: uptime_seconds.cast_unsigned(),
627                        folders: Vec::new(),
628                        bandwidth: BandwidthSnapshot {
629                            upload_total: upload_total.cast_unsigned(),
630                            download_total: download_total.cast_unsigned(),
631                            upload_rate: upload_rate.cast_unsigned(),
632                            download_rate: download_rate.cast_unsigned(),
633                        },
634                        schedule,
635                        rayon_threads: usize::try_from(rayon_threads).unwrap_or_default(),
636                    })
637                },
638            )
639            .optional()
640            .map_err(|error| SyncwebError::operation("failed to load daemon status", error))?;
641
642        if let Some(mut report) = result {
643            let mut folder_stmt = connection
644                .prepare(
645                    "SELECT namespace_id, path, session_active, last_sync_at, entries_synced, errors_json
646                 FROM folder_status_reports",
647                )
648                .map_err(|error| SyncwebError::operation("failed to prepare folder status query", error))?;
649            let folders: Vec<FolderStatusReport> = folder_stmt
650                .query_map([], |row| {
651                    let namespace: String = row.get(0)?;
652                    let path: String = row.get(1)?;
653                    let session_active: i64 = row.get(2)?;
654                    let last_sync_at: Option<i64> = row.get(3)?;
655                    let entries_synced: i64 = row.get(4)?;
656                    let errors_json: String = row.get(5)?;
657                    let errors: Vec<String> = serde_json::from_str(&errors_json).unwrap_or_default();
658                    Ok(FolderStatusReport::new(
659                        namespace,
660                        path,
661                        session_active != 0,
662                        last_sync_at.map(i64::cast_unsigned),
663                        entries_synced.cast_unsigned(),
664                        errors,
665                    ))
666                })
667                .map_err(|error| SyncwebError::operation("failed to query folder statuses", error))?
668                .collect::<std::result::Result<Vec<_>, _>>()
669                .map_err(|error| SyncwebError::operation("failed to read folder status rows", error))?;
670            report.folders = folders;
671            drop(folder_stmt);
672            drop(connection);
673            Ok(Some(report))
674        } else {
675            drop(connection);
676            Ok(None)
677        }
678    }
679
680    /// Remove all daemon status data.
681    ///
682    /// # Errors
683    ///
684    /// Returns an error if the database cannot be written.
685    pub fn remove_status(&self) -> Result<()> {
686        let connection = self
687            .connection
688            .lock()
689            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
690        connection
691            .execute("DELETE FROM daemon_status WHERE id = 1", [])
692            .map_err(|error| SyncwebError::operation("failed to remove daemon status", error))?;
693        connection
694            .execute("DELETE FROM folder_status_reports", [])
695            .map_err(|error| SyncwebError::operation("failed to remove folder status reports", error))?;
696        drop(connection);
697        Ok(())
698    }
699
700    /// Record (or update) the mount directory for a folder namespace.
701    ///
702    /// The registry is advisory: `ls`/`find`/`sort` use it to resolve a
703    /// filesystem path back to a managed folder, but always filter by live
704    /// `FolderManager` membership before trusting a row.
705    ///
706    /// # Errors
707    ///
708    /// Returns an error if the database cannot be written.
709    pub fn upsert_folder_mount(&self, namespace_id: &str, path: &Path) -> Result<()> {
710        let connection = self
711            .connection
712            .lock()
713            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
714        let now = current_timestamp().cast_signed();
715        connection
716            .execute(
717                "INSERT INTO folder_mounts(namespace_id, path, updated_at) VALUES (?1, ?2, ?3)
718                 ON CONFLICT(namespace_id) DO UPDATE SET
719                    path = excluded.path,
720                    updated_at = excluded.updated_at",
721                params![namespace_id, path.to_string_lossy().as_ref(), now],
722            )
723            .map_err(|error| SyncwebError::operation("failed to save folder mount", error))?;
724        drop(connection);
725        Ok(())
726    }
727
728    /// Load all registered folder mount paths as `(namespace_id, path)` pairs.
729    ///
730    /// # Errors
731    ///
732    /// Returns an error if the database cannot be read.
733    pub fn load_folder_mounts(&self) -> Result<Vec<(String, PathBuf)>> {
734        let connection = self
735            .connection
736            .lock()
737            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
738        let mut stmt = connection
739            .prepare("SELECT namespace_id, path FROM folder_mounts")
740            .map_err(|error| SyncwebError::operation("failed to prepare folder mount query", error))?;
741        let rows = stmt
742            .query_map([], |row| {
743                Ok((row.get::<_, String>(0)?, PathBuf::from(row.get::<_, String>(1)?)))
744            })
745            .map_err(|error| SyncwebError::operation("failed to query folder mounts", error))?
746            .collect::<std::result::Result<Vec<_>, _>>()
747            .map_err(|error| SyncwebError::operation("failed to read folder mount rows", error))?;
748        drop(stmt);
749        drop(connection);
750        Ok(rows)
751    }
752
753    /// Remove a folder's mount registration (e.g. on `leave`).
754    ///
755    /// # Errors
756    ///
757    /// Returns an error if the database cannot be written.
758    pub fn remove_folder_mount(&self, namespace_id: &str) -> Result<()> {
759        let connection = self
760            .connection
761            .lock()
762            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
763        connection
764            .execute(
765                "DELETE FROM folder_mounts WHERE namespace_id = ?1",
766                params![namespace_id],
767            )
768            .map_err(|error| SyncwebError::operation("failed to remove folder mount", error))?;
769        drop(connection);
770        Ok(())
771    }
772
773    /// Create a network entry in the database.
774    ///
775    /// # Errors
776    ///
777    /// Returns an error if the database cannot be written.
778    pub fn create_network(&self, network: &Network) -> Result<()> {
779        let connection = self
780            .connection
781            .lock()
782            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
783        let now = current_timestamp().cast_signed();
784        connection.execute(
785            "INSERT INTO networks(id, name, label, owner, shared_secret, created_at, doc_ticket) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
786            params![
787                network.id.to_string(),
788                network.name,
789                network.label,
790                network.owner.to_string(),
791                network.shared_secret.as_ref().map(|s| s.to_vec()),
792                now,
793                network.doc_ticket,
794            ],
795        ).map_err(|error| SyncwebError::operation("failed to create network", error))?;
796        for member in &network.members {
797            connection
798                .execute(
799                    "INSERT INTO network_members(network_id, member) VALUES (?1, ?2)",
800                    params![network.id.to_string(), member.to_string()],
801                )
802                .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
803        }
804        for folder in &network.folders {
805            connection
806                .execute(
807                    "INSERT INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
808                    params![network.id.to_string(), folder.to_string()],
809                )
810                .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
811        }
812        drop(connection);
813        Ok(())
814    }
815
816    /// Delete a network from the database.
817    ///
818    /// # Errors
819    ///
820    /// Returns an error if the network is not found or the database cannot be written.
821    pub fn delete_network(&self, id: NetworkId) -> Result<()> {
822        let connection = self
823            .connection
824            .lock()
825            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
826        let affected = connection
827            .execute("DELETE FROM networks WHERE id = ?1", params![id.to_string()])
828            .map_err(|error| SyncwebError::operation("failed to delete network", error))?;
829        if affected == 0 {
830            drop(connection);
831            return Err(SyncwebError::FolderNotFound(format!("network {id}")));
832        }
833        drop(connection);
834        Ok(())
835    }
836
837    /// Update the membership-doc ticket stored for a network.
838    ///
839    /// # Errors
840    ///
841    /// Returns an error if the database cannot be written.
842    pub fn set_network_doc_ticket(&self, id: NetworkId, doc_ticket: &Option<String>) -> Result<()> {
843        let connection = self
844            .connection
845            .lock()
846            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
847        connection
848            .execute(
849                "UPDATE networks SET doc_ticket = ?1 WHERE id = ?2",
850                params![doc_ticket, id.to_string()],
851            )
852            .map_err(|error| SyncwebError::operation("failed to update network doc ticket", error))?;
853        drop(connection);
854        Ok(())
855    }
856
857    /// Add a member to a network.
858    ///
859    /// # Errors
860    ///
861    /// Returns an error if the database cannot be written.
862    pub fn add_member(&self, network_id: NetworkId, member: PublicKey) -> Result<()> {
863        let connection = self
864            .connection
865            .lock()
866            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
867        connection
868            .execute(
869                "INSERT OR IGNORE INTO network_members(network_id, member) VALUES (?1, ?2)",
870                params![network_id.to_string(), member.to_string()],
871            )
872            .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
873        drop(connection);
874        Ok(())
875    }
876
877    /// Remove a member from a network.
878    ///
879    /// # Errors
880    ///
881    /// Returns an error if the database cannot be written.
882    pub fn remove_member(&self, network_id: NetworkId, member: &PublicKey) -> Result<bool> {
883        let connection = self
884            .connection
885            .lock()
886            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
887        let affected = connection
888            .execute(
889                "DELETE FROM network_members WHERE network_id = ?1 AND member = ?2",
890                params![network_id.to_string(), member.to_string()],
891            )
892            .map_err(|error| SyncwebError::operation("failed to remove network member", error))?;
893        drop(connection);
894        Ok(affected > 0)
895    }
896
897    /// Add a folder to a network.
898    ///
899    /// # Errors
900    ///
901    /// Returns an error if the database cannot be written.
902    pub fn add_folder_to_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
903        let connection = self
904            .connection
905            .lock()
906            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
907        connection
908            .execute(
909                "INSERT OR IGNORE INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
910                params![network_id.to_string(), namespace_id.to_string()],
911            )
912            .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
913        drop(connection);
914        Ok(())
915    }
916
917    /// Remove a folder from a network.
918    ///
919    /// # Errors
920    ///
921    /// Returns an error if the database cannot be written.
922    pub fn remove_folder_from_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
923        let connection = self
924            .connection
925            .lock()
926            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
927        connection
928            .execute(
929                "DELETE FROM network_folders WHERE network_id = ?1 AND namespace_id = ?2",
930                params![network_id.to_string(), namespace_id.to_string()],
931            )
932            .map_err(|error| SyncwebError::operation("failed to remove network folder", error))?;
933        drop(connection);
934        Ok(())
935    }
936
937    /// List all networks from the database.
938    ///
939    /// # Errors
940    ///
941    /// Returns an error if the database cannot be read.
942    pub fn list_networks(&self) -> Result<Vec<Network>> {
943        let connection = self
944            .connection
945            .lock()
946            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
947        let mut stmt = connection
948            .prepare("SELECT id, name, label, owner, shared_secret, doc_ticket FROM networks ORDER BY name")
949            .map_err(|error| SyncwebError::operation("failed to prepare network query", error))?;
950        let networks = stmt
951            .query_map([], |row| {
952                Ok((
953                    row.get::<_, String>(0)?,
954                    row.get::<_, String>(1)?,
955                    row.get::<_, String>(2)?,
956                    row.get::<_, String>(3)?,
957                    row.get::<_, Option<Vec<u8>>>(4)?,
958                    row.get::<_, Option<String>>(5)?,
959                ))
960            })
961            .map_err(|error| SyncwebError::operation("failed to query networks", error))?
962            .collect::<std::result::Result<Vec<_>, _>>()
963            .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
964
965        let mut member_stmt = connection
966            .prepare("SELECT member FROM network_members WHERE network_id = ?1")
967            .map_err(|error| SyncwebError::operation("failed to prepare member query", error))?;
968        let mut folder_stmt = connection
969            .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
970            .map_err(|error| SyncwebError::operation("failed to prepare folder query", error))?;
971
972        let mut result = Vec::new();
973        for (id_str, name, label, owner_str, shared_secret_bytes, doc_ticket) in networks {
974            let network_id = NetworkId::from_str(&id_str)
975                .map_err(|error| SyncwebError::operation("invalid network ID in database", error))?;
976            let owner = parse_public_key(&owner_str)?;
977            let shared_secret = shared_secret_bytes
978                .map(|bytes| -> Result<[u8; 32]> {
979                    bytes.try_into().map_err(|_bytes: Vec<u8>| {
980                        SyncwebError::operation("invalid shared secret length in database", "expected 32 bytes")
981                    })
982                })
983                .transpose()?;
984
985            let members: HashSet<PublicKey> = member_stmt
986                .query_map(params![id_str], |row| row.get::<_, String>(0))
987                .map_err(|error| SyncwebError::operation("failed to query members", error))?
988                .collect::<std::result::Result<Vec<_>, _>>()
989                .map_err(|error| SyncwebError::operation("failed to read member rows", error))?
990                .into_iter()
991                .map(|s| parse_public_key(&s))
992                .collect::<Result<HashSet<_>>>()?;
993
994            let folders: HashSet<NamespaceId> = folder_stmt
995                .query_map(params![id_str], |row| row.get::<_, String>(0))
996                .map_err(|error| SyncwebError::operation("failed to query folders", error))?
997                .collect::<std::result::Result<Vec<_>, _>>()
998                .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?
999                .into_iter()
1000                .map(|s| s.parse())
1001                .collect::<std::result::Result<HashSet<_>, _>>()
1002                .map_err(|error| SyncwebError::operation("invalid folder namespace in database", error))?;
1003
1004            result.push(Network {
1005                id: network_id,
1006                name,
1007                label,
1008                owner,
1009                members,
1010                folders,
1011                shared_secret,
1012                doc_ticket,
1013            });
1014        }
1015        drop(stmt);
1016        drop(member_stmt);
1017        drop(folder_stmt);
1018        drop(connection);
1019        Ok(result)
1020    }
1021
1022    /// Get the installed collection state.
1023    ///
1024    /// # Errors
1025    ///
1026    /// Returns an error if the database cannot be read.
1027    pub fn get_collection_state(&self) -> Result<CollectionState> {
1028        let connection = self
1029            .connection
1030            .lock()
1031            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1032        let mut stmt = connection
1033            .prepare("SELECT collection_id, manifest_hash, current_version FROM installed_collections")
1034            .map_err(|error| SyncwebError::operation("failed to prepare collection query", error))?;
1035        let collections: Vec<(String, String, String)> = stmt
1036            .query_map([], |row| {
1037                Ok((
1038                    row.get::<_, String>(0)?,
1039                    row.get::<_, String>(1)?,
1040                    row.get::<_, String>(2)?,
1041                ))
1042            })
1043            .map_err(|error| SyncwebError::operation("failed to query collections", error))?
1044            .collect::<std::result::Result<Vec<_>, _>>()
1045            .map_err(|error| SyncwebError::operation("failed to read collection rows", error))?;
1046
1047        let mut version_stmt = connection
1048            .prepare("SELECT version, install_path FROM collection_versions WHERE collection_id = ?1")
1049            .map_err(|error| SyncwebError::operation("failed to prepare version query", error))?;
1050
1051        let mut installed = BTreeMap::new();
1052        for (id_str, manifest_hash, current_version) in collections {
1053            let collection_id = Uuid::from_str(&id_str)
1054                .map_err(|error| SyncwebError::operation("invalid collection ID in database", error))?;
1055            let manifest = iroh_blobs::Hash::from_str(&manifest_hash)
1056                .map_err(|error| SyncwebError::operation("invalid manifest hash in database", error))?;
1057
1058            let versions: BTreeMap<String, PathBuf> = version_stmt
1059                .query_map(params![id_str], |row| {
1060                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1061                })
1062                .map_err(|error| SyncwebError::operation("failed to query versions", error))?
1063                .collect::<std::result::Result<Vec<_>, _>>()
1064                .map_err(|error| SyncwebError::operation("failed to read version rows", error))?
1065                .into_iter()
1066                .map(|(v, p)| (v, PathBuf::from(p)))
1067                .collect();
1068
1069            installed.insert(
1070                collection_id,
1071                InstalledCollection {
1072                    manifest,
1073                    versions,
1074                    current: current_version,
1075                },
1076            );
1077        }
1078        drop(stmt);
1079        drop(version_stmt);
1080        drop(connection);
1081        Ok(CollectionState { installed })
1082    }
1083
1084    /// Install a collection version in the database.
1085    ///
1086    /// # Errors
1087    ///
1088    /// Returns an error if the database cannot be written.
1089    pub fn install_collection(
1090        &self,
1091        collection_id: Uuid,
1092        manifest_hash: &iroh_blobs::Hash,
1093        version: &str,
1094        path: &Path,
1095    ) -> Result<()> {
1096        let connection = self
1097            .connection
1098            .lock()
1099            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1100        let now = current_timestamp().cast_signed();
1101        connection
1102            .execute(
1103                "INSERT INTO installed_collections(collection_id, manifest_hash, current_version, installed_at)
1104             VALUES (?1, ?2, ?3, ?4)
1105             ON CONFLICT(collection_id) DO UPDATE SET
1106                manifest_hash = excluded.manifest_hash,
1107                current_version = excluded.current_version,
1108                installed_at = excluded.installed_at",
1109                params![collection_id.to_string(), manifest_hash.to_string(), version, now],
1110            )
1111            .map_err(|error| SyncwebError::operation("failed to install collection", error))?;
1112        connection
1113            .execute(
1114                "INSERT OR IGNORE INTO collection_versions(collection_id, version, install_path) VALUES (?1, ?2, ?3)",
1115                params![collection_id.to_string(), version, path.to_string_lossy().as_ref()],
1116            )
1117            .map_err(|error| SyncwebError::operation("failed to record collection version", error))?;
1118        drop(connection);
1119        Ok(())
1120    }
1121
1122    /// Switch the active version of a collection.
1123    ///
1124    /// # Errors
1125    ///
1126    /// Returns an error if the database cannot be written.
1127    pub fn switch_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
1128        let connection = self
1129            .connection
1130            .lock()
1131            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1132        connection
1133            .execute(
1134                "UPDATE installed_collections SET current_version = ?1 WHERE collection_id = ?2",
1135                params![version, collection_id.to_string()],
1136            )
1137            .map_err(|error| SyncwebError::operation("failed to switch collection version", error))?;
1138        drop(connection);
1139        Ok(())
1140    }
1141
1142    /// Remove a collection version from the database.
1143    ///
1144    /// # Errors
1145    ///
1146    /// Returns an error if the database cannot be written.
1147    pub fn remove_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
1148        let connection = self
1149            .connection
1150            .lock()
1151            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1152        connection
1153            .execute(
1154                "DELETE FROM collection_versions WHERE collection_id = ?1 AND version = ?2",
1155                params![collection_id.to_string(), version],
1156            )
1157            .map_err(|error| SyncwebError::operation("failed to remove collection version", error))?;
1158
1159        let remaining: i64 = connection
1160            .query_row(
1161                "SELECT COUNT(*) FROM collection_versions WHERE collection_id = ?1",
1162                params![collection_id.to_string()],
1163                |row| row.get(0),
1164            )
1165            .map_err(|error| SyncwebError::operation("failed to check remaining versions", error))?;
1166        if remaining == 0 {
1167            connection
1168                .execute(
1169                    "DELETE FROM installed_collections WHERE collection_id = ?1",
1170                    params![collection_id.to_string()],
1171                )
1172                .map_err(|error| SyncwebError::operation("failed to remove installed collection", error))?;
1173        }
1174        drop(connection);
1175        Ok(())
1176    }
1177
1178    /// Get a configuration value.
1179    ///
1180    /// # Errors
1181    ///
1182    /// Returns an error if the database cannot be read.
1183    pub fn get_config(&self, key: &str) -> Result<Option<String>> {
1184        let connection = self
1185            .connection
1186            .lock()
1187            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1188        let result = connection
1189            .query_row("SELECT value FROM app_config WHERE key = ?1", params![key], |row| {
1190                row.get::<_, String>(0)
1191            })
1192            .optional()
1193            .map_err(|error| SyncwebError::operation("failed to get config", error))?;
1194        drop(connection);
1195        Ok(result)
1196    }
1197
1198    /// Set a configuration value.
1199    ///
1200    /// # Errors
1201    ///
1202    /// Returns an error if the database cannot be written.
1203    pub fn set_config(&self, key: &str, value: &str) -> Result<()> {
1204        let connection = self
1205            .connection
1206            .lock()
1207            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1208        let now = current_timestamp().cast_signed();
1209        connection
1210            .execute(
1211                "INSERT INTO app_config(key, value, updated_at) VALUES (?1, ?2, ?3)
1212             ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
1213                params![key, value, now],
1214            )
1215            .map_err(|error| SyncwebError::operation("failed to set config", error))?;
1216        drop(connection);
1217        Ok(())
1218    }
1219
1220    /// Load the application configuration.
1221    ///
1222    /// # Errors
1223    ///
1224    /// Returns an error if the database cannot be read or the config cannot be parsed.
1225    pub fn load_app_config(&self) -> Result<AppConfig> {
1226        let connection = self
1227            .connection
1228            .lock()
1229            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1230        let blob: Option<String> = connection
1231            .query_row("SELECT value FROM app_config WHERE key = 'config_blob'", [], |row| {
1232                row.get(0)
1233            })
1234            .optional()
1235            .map_err(|error| SyncwebError::operation("failed to load app config", error))?;
1236        drop(connection);
1237        blob.map_or_else(
1238            || Ok(AppConfig::default()),
1239            |toml_str| {
1240                toml::from_str(&toml_str).map_err(|error| SyncwebError::operation("failed to parse app config", error))
1241            },
1242        )
1243    }
1244
1245    /// Save the application configuration.
1246    ///
1247    /// # Errors
1248    ///
1249    /// Returns an error if the config cannot be serialized or the database cannot be written.
1250    pub fn save_app_config(&self, config: &AppConfig) -> Result<()> {
1251        let now = current_timestamp().cast_signed();
1252        let blob = toml::to_string_pretty(config)
1253            .map_err(|error| SyncwebError::operation("failed to serialize app config", error))?;
1254        let connection = self
1255            .connection
1256            .lock()
1257            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1258        connection
1259            .execute(
1260                "INSERT INTO app_config(key, value, updated_at) VALUES ('config_blob', ?1, ?2)
1261             ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
1262                params![blob, now],
1263            )
1264            .map_err(|error| SyncwebError::operation("failed to save app config", error))?;
1265        drop(connection);
1266        Ok(())
1267    }
1268
1269    /// Load the filter engine state.
1270    ///
1271    /// # Errors
1272    ///
1273    /// Returns an error if the database cannot be read or the filter engine cannot be created.
1274    pub fn load_filter_engine(&self) -> Result<Option<FilterEngine>> {
1275        let connection = self
1276            .connection
1277            .lock()
1278            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1279        let mut stmt = connection
1280            .prepare("SELECT namespace_id, rule_type, pattern FROM filter_rules ORDER BY priority, id")
1281            .map_err(|error| SyncwebError::operation("failed to prepare filter query", error))?;
1282        let rows: Vec<(Option<String>, String, String)> = stmt
1283            .query_map([], |row| {
1284                Ok((
1285                    row.get::<_, Option<String>>(0)?,
1286                    row.get::<_, String>(1)?,
1287                    row.get::<_, String>(2)?,
1288                ))
1289            })
1290            .map_err(|error| SyncwebError::operation("failed to query filters", error))?
1291            .collect::<std::result::Result<Vec<_>, _>>()
1292            .map_err(|error| SyncwebError::operation("failed to read filter rows", error))?;
1293        drop(stmt);
1294        drop(connection);
1295
1296        if rows.is_empty() {
1297            return Ok(None);
1298        }
1299
1300        let mut global_rules = Vec::new();
1301        let mut folder_rules: HashMap<String, Vec<FilterRule>> = HashMap::new();
1302        for (namespace_id, rule_type, pattern) in rows {
1303            let action = match rule_type.as_str() {
1304                "include" => crate::filter::FilterAction::Accept,
1305                "exclude" => crate::filter::FilterAction::Reject,
1306                _ => continue,
1307            };
1308            let criteria = crate::filter::MatchCriteria {
1309                name: Some(pattern),
1310                ..Default::default()
1311            };
1312            let rule = FilterRule::new(action, criteria);
1313            match namespace_id {
1314                Some(ns) => folder_rules.entry(ns).or_default().push(rule),
1315                None => global_rules.push(rule),
1316            }
1317        }
1318
1319        let filter_config = crate::filter::FilterConfig {
1320            rules: global_rules,
1321            folders: folder_rules,
1322            default_action: FilterAction::default(),
1323        };
1324        FilterEngine::new(filter_config).map(Some)
1325    }
1326
1327    /// Save filter rules to the database.
1328    ///
1329    /// # Errors
1330    ///
1331    /// Returns an error if the database cannot be written.
1332    pub fn save_filter_rules(&self, rules: &[FilterEntry]) -> Result<()> {
1333        let connection = self
1334            .connection
1335            .lock()
1336            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1337        let now = current_timestamp().cast_signed();
1338        connection
1339            .execute("DELETE FROM filter_rules", [])
1340            .map_err(|error| SyncwebError::operation("failed to clear filter rules", error))?;
1341        for entry in rules {
1342            let rule_type = if entry.size > 0 { "exclude" } else { "include" };
1343            connection
1344                .execute(
1345                    "INSERT INTO filter_rules(namespace_id, rule_type, pattern, priority, updated_at)
1346                 VALUES (NULL, ?1, ?2, 0, ?3)",
1347                    params![rule_type, entry.path.to_string_lossy().as_ref(), now],
1348                )
1349                .map_err(|error| SyncwebError::operation("failed to save filter rule", error))?;
1350        }
1351        drop(connection);
1352        Ok(())
1353    }
1354
1355    /// Load all public subscription hashes and their sizes.
1356    ///
1357    /// # Errors
1358    ///
1359    /// Returns an error if the database cannot be read.
1360    pub fn load_subscriptions(&self) -> Result<HashSet<iroh_blobs::Hash>> {
1361        let connection = self
1362            .connection
1363            .lock()
1364            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1365        let mut stmt = connection
1366            .prepare("SELECT hash FROM public_subscriptions")
1367            .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1368        let hashes: HashSet<iroh_blobs::Hash> = stmt
1369            .query_map([], |row| {
1370                let text: String = row.get(0)?;
1371                Ok(text)
1372            })
1373            .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1374            .collect::<std::result::Result<Vec<_>, _>>()
1375            .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1376            .into_iter()
1377            .filter_map(|h| h.parse::<iroh_blobs::Hash>().ok())
1378            .collect();
1379        drop(stmt);
1380        drop(connection);
1381        Ok(hashes)
1382    }
1383
1384    /// Load subscription hashes with their cached sizes.
1385    ///
1386    /// # Errors
1387    ///
1388    /// Returns an error if the database cannot be read.
1389    pub fn load_subscriptions_with_sizes(&self) -> Result<Vec<(iroh_blobs::Hash, u64)>> {
1390        let connection = self
1391            .connection
1392            .lock()
1393            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1394        let mut stmt = connection
1395            .prepare("SELECT hash, size FROM public_subscriptions")
1396            .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1397        let results: Vec<(iroh_blobs::Hash, u64)> = stmt
1398            .query_map([], |row| {
1399                let text: String = row.get(0)?;
1400                let size: i64 = row.get(1)?;
1401                Ok((text, size))
1402            })
1403            .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1404            .collect::<std::result::Result<Vec<_>, _>>()
1405            .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1406            .into_iter()
1407            .filter_map(|(h, size)| {
1408                let hash = h.parse::<iroh_blobs::Hash>().ok()?;
1409                Some((hash, size.cast_unsigned()))
1410            })
1411            .collect();
1412        drop(stmt);
1413        drop(connection);
1414        Ok(results)
1415    }
1416
1417    /// Persist a public blob subscription.
1418    ///
1419    /// # Errors
1420    ///
1421    /// Returns an error if the database cannot be written.
1422    pub fn save_subscription(&self, hash: &iroh_blobs::Hash, size: u64) -> Result<()> {
1423        let connection = self
1424            .connection
1425            .lock()
1426            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1427        let now = current_timestamp().cast_signed();
1428        connection
1429            .execute(
1430                "INSERT OR IGNORE INTO public_subscriptions(hash, size, subscribed_at) VALUES (?1, ?2, ?3)",
1431                params![hash.to_string(), size.cast_signed(), now],
1432            )
1433            .map_err(|error| SyncwebError::operation("failed to save subscription", error))?;
1434        drop(connection);
1435        Ok(())
1436    }
1437
1438    /// Remove a public blob subscription.
1439    ///
1440    /// # Errors
1441    ///
1442    /// Returns an error if the database cannot be written.
1443    pub fn remove_subscription(&self, hash: &iroh_blobs::Hash) -> Result<()> {
1444        let connection = self
1445            .connection
1446            .lock()
1447            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1448        connection
1449            .execute(
1450                "DELETE FROM public_subscriptions WHERE hash = ?1",
1451                params![hash.to_string()],
1452            )
1453            .map_err(|error| SyncwebError::operation("failed to remove subscription", error))?;
1454        drop(connection);
1455        Ok(())
1456    }
1457
1458    // ------------------------------------------------------------------
1459    // Share registry methods
1460    // ------------------------------------------------------------------
1461
1462    /// Persist a folder share record (upsert by namespace + access).
1463    ///
1464    /// # Errors
1465    ///
1466    /// Returns an error if the database write fails.
1467    pub fn add_share(&self, namespace_id: &str, access: &str, ticket: &str) -> Result<()> {
1468        let connection = self
1469            .connection
1470            .lock()
1471            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1472        connection
1473            .execute(
1474                "INSERT INTO shares (namespace_id, access, ticket, created_at)
1475                 VALUES (?1, ?2, ?3, ?4)
1476                 ON CONFLICT(namespace_id, access)
1477                 DO UPDATE SET ticket = excluded.ticket",
1478                params![namespace_id, access, ticket, current_timestamp().cast_signed()],
1479            )
1480            .map_err(|error| SyncwebError::operation("failed to save share record", error))?;
1481        drop(connection);
1482        Ok(())
1483    }
1484
1485    /// Remove a persisted folder share record.
1486    ///
1487    /// # Errors
1488    ///
1489    /// Returns an error if the database write fails.
1490    pub fn remove_share(&self, namespace_id: &str, access: &str) -> Result<()> {
1491        let connection = self
1492            .connection
1493            .lock()
1494            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1495        connection
1496            .execute(
1497                "DELETE FROM shares WHERE namespace_id = ?1 AND access = ?2",
1498                params![namespace_id, access],
1499            )
1500            .map_err(|error| SyncwebError::operation("failed to remove share record", error))?;
1501        drop(connection);
1502        Ok(())
1503    }
1504
1505    /// Load all persisted folder share records as `(namespace_id, access, ticket)`.
1506    ///
1507    /// # Errors
1508    ///
1509    /// Returns an error if the database cannot be read.
1510    pub fn list_shares(&self) -> Result<Vec<(String, String, String)>> {
1511        let connection = self
1512            .connection
1513            .lock()
1514            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1515        let mut stmt = connection
1516            .prepare("SELECT namespace_id, access, ticket FROM shares ORDER BY created_at")
1517            .map_err(|error| SyncwebError::operation("failed to prepare share query", error))?;
1518        let rows = stmt
1519            .query_map([], |row| {
1520                Ok((
1521                    row.get::<_, String>(0)?,
1522                    row.get::<_, String>(1)?,
1523                    row.get::<_, String>(2)?,
1524                ))
1525            })
1526            .map_err(|error| SyncwebError::operation("failed to query shares", error))?
1527            .collect::<std::result::Result<Vec<_>, _>>()
1528            .map_err(|error| SyncwebError::operation("failed to read share rows", error))?;
1529        drop(stmt);
1530        drop(connection);
1531        Ok(rows)
1532    }
1533
1534    // ------------------------------------------------------------------
1535    // Workspace manifest methods (replaces .syncweb-collection.json)
1536    // ------------------------------------------------------------------
1537
1538    /// Save a workspace manifest (serialized bytes) keyed by source path.
1539    ///
1540    /// # Errors
1541    ///
1542    /// Returns an error if the database write fails.
1543    pub fn save_workspace_manifest(&self, source_path: &str, manifest_bytes: &[u8], collection_id: &str) -> Result<()> {
1544        let connection = self
1545            .connection
1546            .lock()
1547            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1548        let now = current_timestamp().cast_signed();
1549        connection
1550            .execute(
1551                "INSERT OR REPLACE INTO workspace_manifests(source_path, manifest_bytes, collection_id, updated_at)
1552             VALUES (?1, ?2, ?3, ?4)",
1553                params![source_path, manifest_bytes, collection_id, now],
1554            )
1555            .map_err(|error| SyncwebError::operation("failed to save workspace manifest", error))?;
1556        drop(connection);
1557        Ok(())
1558    }
1559
1560    /// Load a workspace manifest by source path.
1561    ///
1562    /// # Errors
1563    ///
1564    /// Returns an error if the database query fails.
1565    pub fn load_workspace_manifest(&self, source_path: &str) -> Result<Option<Vec<u8>>> {
1566        let connection = self
1567            .connection
1568            .lock()
1569            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1570        let result: Option<Vec<u8>> = connection
1571            .query_row(
1572                "SELECT manifest_bytes FROM workspace_manifests WHERE source_path = ?1",
1573                params![source_path],
1574                |row| row.get(0),
1575            )
1576            .optional()
1577            .map_err(|error| SyncwebError::operation("failed to load workspace manifest", error))?;
1578        drop(connection);
1579        Ok(result)
1580    }
1581
1582    /// Delete a workspace manifest.
1583    ///
1584    /// # Errors
1585    ///
1586    /// Returns an error if the database write fails.
1587    pub fn delete_workspace_manifest(&self, source_path: &str) -> Result<()> {
1588        let connection = self
1589            .connection
1590            .lock()
1591            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1592        connection
1593            .execute(
1594                "DELETE FROM workspace_manifests WHERE source_path = ?1",
1595                params![source_path],
1596            )
1597            .map_err(|error| SyncwebError::operation("failed to delete workspace manifest", error))?;
1598        drop(connection);
1599        Ok(())
1600    }
1601
1602    // ------------------------------------------------------------------
1603    // Blob-folder index methods (network access control)
1604    // ------------------------------------------------------------------
1605
1606    /// Record a blob→folder association.
1607    ///
1608    /// # Errors
1609    ///
1610    /// Returns an error if the database write fails.
1611    pub fn record_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> Result<()> {
1612        let connection = self
1613            .connection
1614            .lock()
1615            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1616        let now = current_timestamp().cast_signed();
1617        connection
1618            .execute(
1619                "INSERT OR IGNORE INTO blob_folders(content_hash, namespace_id, entry_key, added_at)
1620             VALUES (?1, ?2, ?3, ?4)",
1621                params![content_hash.as_slice(), namespace_id, entry_key, now,],
1622            )
1623            .map_err(|error| SyncwebError::operation("failed to record blob folder association", error))?;
1624        drop(connection);
1625        Ok(())
1626    }
1627
1628    /// Remove a blob→folder association.
1629    ///
1630    /// # Errors
1631    ///
1632    /// Returns an error if the database write fails.
1633    pub fn remove_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> Result<()> {
1634        let connection = self
1635            .connection
1636            .lock()
1637            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1638        connection
1639            .execute(
1640                "DELETE FROM blob_folders WHERE content_hash = ?1 AND namespace_id = ?2 AND entry_key = ?3",
1641                params![content_hash.as_slice(), namespace_id, entry_key],
1642            )
1643            .map_err(|error| SyncwebError::operation("failed to remove blob folder association", error))?;
1644        drop(connection);
1645        Ok(())
1646    }
1647
1648    /// List all namespace IDs associated with a network.
1649    ///
1650    /// # Errors
1651    ///
1652    /// Returns an error if the database query fails.
1653    pub fn folders_for_network(&self, network_id: &str) -> Result<Vec<String>> {
1654        let connection = self
1655            .connection
1656            .lock()
1657            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1658        let mut stmt = connection
1659            .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
1660            .map_err(|error| SyncwebError::operation("failed to prepare folders query", error))?;
1661        let folders: Vec<String> = stmt
1662            .query_map(params![network_id], |row| row.get(0))
1663            .map_err(|error| SyncwebError::operation("failed to query folders", error))?
1664            .collect::<std::result::Result<Vec<_>, _>>()
1665            .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?;
1666        drop(stmt);
1667        drop(connection);
1668        Ok(folders)
1669    }
1670
1671    /// List all network IDs that contain a given folder namespace.
1672    ///
1673    /// # Errors
1674    ///
1675    /// Returns an error if the database query fails.
1676    pub fn networks_for_folder(&self, namespace_id: &str) -> Result<Vec<String>> {
1677        let connection = self
1678            .connection
1679            .lock()
1680            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1681        let mut stmt = connection
1682            .prepare("SELECT network_id FROM network_folders WHERE namespace_id = ?1")
1683            .map_err(|error| SyncwebError::operation("failed to prepare networks query", error))?;
1684        let networks: Vec<String> = stmt
1685            .query_map(params![namespace_id], |row| row.get(0))
1686            .map_err(|error| SyncwebError::operation("failed to query networks", error))?
1687            .collect::<std::result::Result<Vec<_>, _>>()
1688            .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
1689        drop(stmt);
1690        drop(connection);
1691        Ok(networks)
1692    }
1693
1694    /// List all member `PublicKey` strings for a network.
1695    ///
1696    /// # Errors
1697    ///
1698    /// Returns an error if the database query fails.
1699    pub fn members_of_network(&self, network_id: &str) -> Result<Vec<String>> {
1700        let connection = self
1701            .connection
1702            .lock()
1703            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1704        let mut stmt = connection
1705            .prepare("SELECT member FROM network_members WHERE network_id = ?1")
1706            .map_err(|error| SyncwebError::operation("failed to prepare members query", error))?;
1707        let members: Vec<String> = stmt
1708            .query_map(params![network_id], |row| row.get(0))
1709            .map_err(|error| SyncwebError::operation("failed to query members", error))?
1710            .collect::<std::result::Result<Vec<_>, _>>()
1711            .map_err(|error| SyncwebError::operation("failed to read member rows", error))?;
1712        drop(stmt);
1713        drop(connection);
1714        Ok(members)
1715    }
1716
1717    /// Returns true if the given peer is a member of any network.
1718    /// For the per-network daemon model, a daemon only manages ONE network.
1719    ///
1720    /// # Errors
1721    ///
1722    /// Returns an error if the database query fails.
1723    pub fn is_peer_network_member(&self, peer_public_key: &str) -> Result<bool> {
1724        let connection = self
1725            .connection
1726            .lock()
1727            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1728        let exists: bool = connection
1729            .query_row(
1730                "SELECT 1 FROM network_members WHERE member = ?1 LIMIT 1",
1731                params![peer_public_key],
1732                |_| Ok(()),
1733            )
1734            .optional()
1735            .map_err(|error| SyncwebError::operation("failed to check peer membership", error))?
1736            .is_some();
1737        drop(connection);
1738        Ok(exists)
1739    }
1740
1741    // ------------------------------------------------------------------
1742    // Transfer roots and job methods
1743    // ------------------------------------------------------------------
1744
1745    /// Add or update a configured materialization root.
1746    ///
1747    /// # Errors
1748    ///
1749    /// Returns an error if the database write fails.
1750    pub fn upsert_storage_root(&self, root: &StorageRootRecord) -> Result<()> {
1751        let connection = self
1752            .connection
1753            .lock()
1754            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1755        let now = current_timestamp().cast_signed();
1756        connection
1757            .execute(
1758                "INSERT INTO storage_roots(id, path, min_free, enabled, created_at, updated_at)
1759             VALUES (?1, ?2, ?3, ?4, ?5, ?5)
1760             ON CONFLICT(id) DO UPDATE SET
1761                path = excluded.path,
1762                min_free = excluded.min_free,
1763                enabled = excluded.enabled,
1764                updated_at = excluded.updated_at",
1765                params![
1766                    root.id,
1767                    root.path.to_string_lossy().as_ref(),
1768                    root.min_free.cast_signed(),
1769                    i64::from(root.enabled),
1770                    now,
1771                ],
1772            )
1773            .map_err(|error| SyncwebError::operation("failed to save storage root", error))?;
1774        drop(connection);
1775        Ok(())
1776    }
1777
1778    /// List configured materialization roots.
1779    ///
1780    /// # Errors
1781    ///
1782    /// Returns an error if the database query fails.
1783    pub fn list_storage_roots(&self) -> Result<Vec<StorageRootRecord>> {
1784        let connection = self
1785            .connection
1786            .lock()
1787            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1788        let mut statement = connection
1789            .prepare("SELECT id, path, min_free, enabled FROM storage_roots ORDER BY id")
1790            .map_err(|error| SyncwebError::operation("failed to prepare storage root query", error))?;
1791        let roots = statement
1792            .query_map([], |row| {
1793                let min_free: i64 = row.get(2)?;
1794                let enabled: i64 = row.get(3)?;
1795                Ok(StorageRootRecord {
1796                    id: row.get(0)?,
1797                    path: PathBuf::from(row.get::<_, String>(1)?),
1798                    min_free: min_free.cast_unsigned(),
1799                    enabled: enabled != 0,
1800                })
1801            })
1802            .map_err(|error| SyncwebError::operation("failed to query storage roots", error))?
1803            .collect::<std::result::Result<Vec<_>, _>>()
1804            .map_err(|error| SyncwebError::operation("failed to read storage root rows", error))?;
1805        drop(statement);
1806        drop(connection);
1807        Ok(roots)
1808    }
1809
1810    /// Remove a storage root.
1811    ///
1812    /// # Errors
1813    ///
1814    /// Returns an error if the database write fails.
1815    pub fn remove_storage_root(&self, id: &str) -> Result<()> {
1816        let connection = self
1817            .connection
1818            .lock()
1819            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1820        connection
1821            .execute("DELETE FROM storage_roots WHERE id = ?1", params![id])
1822            .map_err(|error| SyncwebError::operation("failed to remove storage root", error))?;
1823        drop(connection);
1824        Ok(())
1825    }
1826
1827    /// Enqueue a materialization or fetch job and return its durable ID.
1828    ///
1829    /// # Errors
1830    ///
1831    /// Returns an error if the database write fails.
1832    pub fn enqueue_transfer_job(&self, job: &NewTransferJob<'_>) -> Result<String> {
1833        let id = Uuid::new_v4().to_string();
1834        let connection = self
1835            .connection
1836            .lock()
1837            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1838        let now = current_timestamp().cast_signed();
1839        connection
1840            .execute(
1841                "INSERT INTO transfer_jobs(
1842                    id, namespace_id, entry_key, hash, size, root_id, destination,
1843                    state, created_at, updated_at
1844                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'queued', ?8, ?8)",
1845                params![
1846                    id,
1847                    job.namespace_id,
1848                    job.entry_key,
1849                    job.hash.as_slice(),
1850                    job.size.cast_signed(),
1851                    job.root_id,
1852                    job.destination.map(|path| path.to_string_lossy().into_owned()),
1853                    now,
1854                ],
1855            )
1856            .map_err(|error| SyncwebError::operation("failed to enqueue transfer job", error))?;
1857        drop(connection);
1858        Ok(id)
1859    }
1860
1861    /// List durable transfer jobs, optionally scoped by namespace and state.
1862    ///
1863    /// # Errors
1864    ///
1865    /// Returns an error if the database query fails.
1866    pub fn list_transfer_jobs(
1867        &self,
1868        namespace_id: Option<&str>,
1869        state: Option<&str>,
1870    ) -> Result<Vec<TransferJobRecord>> {
1871        let connection = self
1872            .connection
1873            .lock()
1874            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1875        let jobs = match (namespace_id, state) {
1876            (Some(namespace_filter), Some(state_filter)) => {
1877                let mut statement = connection
1878                    .prepare(
1879                        "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1880                            state, bytes_transferred, peer_count, eta_seconds, retries,
1881                            error_message, created_at, updated_at
1882                         FROM transfer_jobs
1883                         WHERE namespace_id = ?1 AND state = ?2
1884                         ORDER BY created_at, id",
1885                    )
1886                    .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1887                statement
1888                    .query_map(params![namespace_filter, state_filter], transfer_job_from_row)
1889                    .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1890                    .collect::<std::result::Result<Vec<_>, _>>()
1891                    .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1892            }
1893            (Some(namespace_filter), None) => {
1894                let mut statement = connection
1895                    .prepare(
1896                        "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1897                            state, bytes_transferred, peer_count, eta_seconds, retries,
1898                            error_message, created_at, updated_at
1899                         FROM transfer_jobs
1900                         WHERE namespace_id = ?1
1901                         ORDER BY created_at, id",
1902                    )
1903                    .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1904                statement
1905                    .query_map(params![namespace_filter], transfer_job_from_row)
1906                    .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1907                    .collect::<std::result::Result<Vec<_>, _>>()
1908                    .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1909            }
1910            (None, Some(state_filter)) => {
1911                let mut statement = connection
1912                    .prepare(
1913                        "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1914                            state, bytes_transferred, peer_count, eta_seconds, retries,
1915                            error_message, created_at, updated_at
1916                         FROM transfer_jobs
1917                         WHERE state = ?1
1918                         ORDER BY created_at, id",
1919                    )
1920                    .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1921                statement
1922                    .query_map(params![state_filter], transfer_job_from_row)
1923                    .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1924                    .collect::<std::result::Result<Vec<_>, _>>()
1925                    .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1926            }
1927            (None, None) => {
1928                let mut statement = connection
1929                    .prepare(
1930                        "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1931                            state, bytes_transferred, peer_count, eta_seconds, retries,
1932                            error_message, created_at, updated_at
1933                         FROM transfer_jobs
1934                         ORDER BY created_at, id",
1935                    )
1936                    .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1937                statement
1938                    .query_map([], transfer_job_from_row)
1939                    .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1940                    .collect::<std::result::Result<Vec<_>, _>>()
1941                    .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1942            }
1943        };
1944        drop(connection);
1945        Ok(jobs)
1946    }
1947
1948    /// Update a job lifecycle state and optional error.
1949    ///
1950    /// # Errors
1951    ///
1952    /// Returns an error if the database write fails.
1953    pub fn update_transfer_job_state(&self, id: &str, state: &str, error_message: Option<&str>) -> Result<()> {
1954        let connection = self
1955            .connection
1956            .lock()
1957            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1958        let now = current_timestamp().cast_signed();
1959        let updated = connection
1960            .execute(
1961                "UPDATE transfer_jobs
1962                 SET state = ?1, error_message = ?2, updated_at = ?3
1963                 WHERE id = ?4",
1964                params![state, error_message, now, id],
1965            )
1966            .map_err(|error| SyncwebError::operation("failed to update transfer job state", error))?;
1967        if updated == 0 {
1968            return Err(SyncwebError::InvalidConfig(format!("transfer job not found: {id}")));
1969        }
1970        drop(connection);
1971        Ok(())
1972    }
1973
1974    /// Transition a job only if it is still in the expected state.
1975    ///
1976    /// # Errors
1977    ///
1978    /// Returns an error if the database write fails.
1979    pub fn transition_transfer_job_state(
1980        &self,
1981        id: &str,
1982        expected_state: &str,
1983        state: &str,
1984        error_message: Option<&str>,
1985    ) -> Result<bool> {
1986        let connection = self
1987            .connection
1988            .lock()
1989            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1990        let now = current_timestamp().cast_signed();
1991        let updated = connection
1992            .execute(
1993                "UPDATE transfer_jobs
1994                 SET state = ?1, error_message = ?2, updated_at = ?3
1995                 WHERE id = ?4 AND state = ?5",
1996                params![state, error_message, now, id, expected_state],
1997            )
1998            .map_err(|error| SyncwebError::operation("failed to transition transfer job state", error))?;
1999        drop(connection);
2000        Ok(updated != 0)
2001    }
2002
2003    /// Assign a queued job to a root and its stable materialization path.
2004    ///
2005    /// # Errors
2006    ///
2007    /// Returns an error if the database write fails.
2008    pub fn assign_transfer_job(&self, id: &str, root_id: &str, destination: &Path) -> Result<()> {
2009        let connection = self
2010            .connection
2011            .lock()
2012            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2013        let now = current_timestamp().cast_signed();
2014        let updated = connection
2015            .execute(
2016                "UPDATE transfer_jobs
2017                 SET root_id = ?1, destination = ?2, updated_at = ?3
2018                 WHERE id = ?4 AND state = 'queued'",
2019                params![root_id, destination.to_string_lossy().as_ref(), now, id],
2020            )
2021            .map_err(|error| SyncwebError::operation("failed to assign transfer job", error))?;
2022        if updated == 0 {
2023            return Err(SyncwebError::InvalidConfig(format!(
2024                "transfer job is missing or not queued: {id}"
2025            )));
2026        }
2027        drop(connection);
2028        Ok(())
2029    }
2030
2031    /// Update per-job transfer counters and retry metadata.
2032    ///
2033    /// # Errors
2034    ///
2035    /// Returns an error if the database write fails.
2036    pub fn update_transfer_job_progress(
2037        &self,
2038        id: &str,
2039        bytes_transferred: u64,
2040        peer_count: u64,
2041        eta_seconds: Option<u64>,
2042        retries: u32,
2043    ) -> Result<()> {
2044        let connection = self
2045            .connection
2046            .lock()
2047            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2048        let now = current_timestamp().cast_signed();
2049        let updated = connection
2050            .execute(
2051                "UPDATE transfer_jobs
2052                 SET bytes_transferred = ?1, peer_count = ?2, eta_seconds = ?3,
2053                     retries = ?4, updated_at = ?5
2054                 WHERE id = ?6",
2055                params![
2056                    bytes_transferred.cast_signed(),
2057                    peer_count.cast_signed(),
2058                    eta_seconds.map(u64::cast_signed),
2059                    retries.cast_signed(),
2060                    now,
2061                    id,
2062                ],
2063            )
2064            .map_err(|error| SyncwebError::operation("failed to update transfer job progress", error))?;
2065        if updated == 0 {
2066            return Err(SyncwebError::InvalidConfig(format!("transfer job not found: {id}")));
2067        }
2068        drop(connection);
2069        Ok(())
2070    }
2071
2072    /// Requeue a failed or paused job while recording a retry attempt.
2073    ///
2074    /// # Errors
2075    ///
2076    /// Returns an error if the database write fails.
2077    pub fn retry_transfer_job(&self, id: &str) -> Result<()> {
2078        let connection = self
2079            .connection
2080            .lock()
2081            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2082        let now = current_timestamp().cast_signed();
2083        let updated = connection
2084            .execute(
2085                "UPDATE transfer_jobs
2086                 SET state = 'queued', error_message = NULL,
2087                     retries = retries + 1, updated_at = ?1
2088                 WHERE id = ?2 AND state IN ('failed', 'paused', 'cancelled')",
2089                params![now, id],
2090            )
2091            .map_err(|error| SyncwebError::operation("failed to retry transfer job", error))?;
2092        if updated == 0 {
2093            return Err(SyncwebError::InvalidConfig(format!(
2094                "transfer job is missing or not retryable: {id}"
2095            )));
2096        }
2097        drop(connection);
2098        Ok(())
2099    }
2100
2101    /// Requeue jobs interrupted by a daemon restart.
2102    ///
2103    /// # Errors
2104    ///
2105    /// Returns an error if the database write fails.
2106    pub fn recover_transfer_jobs(&self) -> Result<usize> {
2107        let connection = self
2108            .connection
2109            .lock()
2110            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2111        let now = current_timestamp().cast_signed();
2112        let recovered = connection
2113            .execute(
2114                "UPDATE transfer_jobs
2115                 SET state = 'queued', updated_at = ?1
2116                 WHERE state IN ('fetching', 'materializing')",
2117                params![now],
2118            )
2119            .map_err(|error| SyncwebError::operation("failed to recover transfer jobs", error))?;
2120        drop(connection);
2121        Ok(recovered)
2122    }
2123
2124    /// Return bytes reserved by pending jobs for a root.
2125    ///
2126    /// # Errors
2127    ///
2128    /// Returns an error if the database query fails.
2129    pub fn reserved_transfer_bytes(&self, root_id: &str) -> Result<u64> {
2130        let connection = self
2131            .connection
2132            .lock()
2133            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2134        let reserved: i64 = connection
2135            .query_row(
2136                "SELECT COALESCE(SUM(size), 0)
2137                 FROM transfer_jobs
2138                 WHERE root_id = ?1
2139                   AND state IN ('queued', 'fetching', 'materializing', 'paused')",
2140                params![root_id],
2141                |row| row.get(0),
2142            )
2143            .map_err(|error| SyncwebError::operation("failed to query reserved transfer bytes", error))?;
2144        drop(connection);
2145        Ok(reserved.cast_unsigned())
2146    }
2147
2148    // ------------------------------------------------------------------
2149    // Sync checkpoint methods
2150    // ------------------------------------------------------------------
2151
2152    /// Create a new sync checkpoint session.
2153    ///
2154    /// # Errors
2155    ///
2156    /// Returns an error if the database write fails.
2157    pub fn create_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
2158        let connection = self
2159            .connection
2160            .lock()
2161            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2162        let now = current_timestamp().cast_signed();
2163        connection
2164            .execute(
2165                "INSERT INTO sync_checkpoints(namespace_id, session_id, started_at, last_updated_at, status)
2166             VALUES (?1, ?2, ?3, ?4, 'running')",
2167                params![namespace_id, session_id, now, now],
2168            )
2169            .map_err(|error| SyncwebError::operation("failed to create sync checkpoint", error))?;
2170        drop(connection);
2171        Ok(())
2172    }
2173
2174    /// Upsert a sync entry progress record.
2175    ///
2176    /// # Errors
2177    ///
2178    /// Returns an error if the database write fails.
2179    pub fn upsert_sync_entry(&self, p: &SyncEntryParams<'_>) -> Result<()> {
2180        let connection = self
2181            .connection
2182            .lock()
2183            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2184        let now = current_timestamp().cast_signed();
2185        connection
2186            .execute(
2187                "INSERT INTO sync_entry_progress(namespace_id, session_id, entry_key, hash, size, status, retries, error_message, updated_at)
2188             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2189             ON CONFLICT(namespace_id, session_id, entry_key) DO UPDATE SET
2190                status = excluded.status,
2191                retries = excluded.retries,
2192                error_message = excluded.error_message,
2193                updated_at = excluded.updated_at",
2194                params![p.namespace_id, p.session_id, p.entry_key, p.hash, p.size.cast_signed(), p.status, p.retries.cast_signed(), p.error_message, now],
2195            )
2196            .map_err(|error| SyncwebError::operation("failed to upsert sync entry", error))?;
2197        drop(connection);
2198        Ok(())
2199    }
2200
2201    /// List sync entries for a session filtered by status.
2202    ///
2203    /// # Errors
2204    ///
2205    /// Returns an error if the database query fails.
2206    pub fn list_sync_entries(
2207        &self,
2208        namespace_id: &str,
2209        session_id: &str,
2210        status: &str,
2211    ) -> Result<Vec<crate::sync::checkpoint::EntryProgress>> {
2212        let connection = self
2213            .connection
2214            .lock()
2215            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2216        let mut stmt = connection
2217            .prepare(
2218                "SELECT entry_key, hash, size, status, retries, error_message
2219             FROM sync_entry_progress
2220             WHERE namespace_id = ?1 AND session_id = ?2 AND status = ?3",
2221            )
2222            .map_err(|error| SyncwebError::operation("failed to prepare sync entry query", error))?;
2223        let entries = stmt
2224            .query_map(params![namespace_id, session_id, status], |row| {
2225                let entry_key: Vec<u8> = row.get(0)?;
2226                let hash_bytes: Vec<u8> = row.get(1)?;
2227                let hash_str = String::from_utf8_lossy(&hash_bytes);
2228                let hash = hash_str.parse::<iroh_blobs::Hash>().unwrap_or(iroh_blobs::Hash::EMPTY);
2229                let size: i64 = row.get(2)?;
2230                let status_str: String = row.get(3)?;
2231                let retries: i64 = row.get(4)?;
2232                let error_message: Option<String> = row.get(5)?;
2233                Ok(crate::sync::checkpoint::EntryProgress {
2234                    entry_key,
2235                    hash,
2236                    size: size.cast_unsigned(),
2237                    status: status_str,
2238                    retries: u32::try_from(retries).unwrap_or(0),
2239                    error_message,
2240                })
2241            })
2242            .map_err(|error| SyncwebError::operation("failed to query sync entries", error))?
2243            .collect::<std::result::Result<Vec<_>, _>>()
2244            .map_err(|error| SyncwebError::operation("failed to read sync entry rows", error))?;
2245        drop(stmt);
2246        drop(connection);
2247        Ok(entries)
2248    }
2249
2250    /// Get checkpoint progress for a session.
2251    ///
2252    /// # Errors
2253    ///
2254    /// Returns an error if the database query fails.
2255    pub fn get_checkpoint_progress(
2256        &self,
2257        namespace_id: &str,
2258        session_id: &str,
2259    ) -> Result<crate::sync::checkpoint::CheckpointProgress> {
2260        let connection = self
2261            .connection
2262            .lock()
2263            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2264        let (total, completed, failed, skipped, pending, bytes_transferred, bytes_total): (
2265            i64, i64, i64, i64, i64, i64, Option<i64>,
2266        ) = connection
2267            .query_row(
2268                "SELECT
2269                (SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2),
2270                COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
2271                COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'failed'), 0),
2272                COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'skipped'), 0),
2273                COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'pending'), 0),
2274                COALESCE((SELECT SUM(size) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
2275                (SELECT bytes_total FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2)",
2276                params![namespace_id, session_id],
2277                |row| Ok((
2278                    row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?
2279                )),
2280            )
2281            .map_err(|error| SyncwebError::operation("failed to query checkpoint progress", error))?;
2282        drop(connection);
2283        let percentage = if total > 0 {
2284            let sum = completed.checked_add(skipped).unwrap_or(0);
2285            let pct_int = sum.checked_mul(100).and_then(|n| n.checked_div(total)).unwrap_or(0);
2286            f64::from(i32::try_from(pct_int).unwrap_or(0))
2287        } else {
2288            0.0
2289        };
2290        Ok(crate::sync::checkpoint::CheckpointProgress {
2291            total: usize::try_from(total).unwrap_or(0),
2292            completed: usize::try_from(completed).unwrap_or(0),
2293            failed: usize::try_from(failed).unwrap_or(0),
2294            skipped: usize::try_from(skipped).unwrap_or(0),
2295            pending: usize::try_from(pending).unwrap_or(0),
2296            bytes_transferred: bytes_transferred.cast_unsigned(),
2297            bytes_total: bytes_total.map(i64::cast_unsigned),
2298            percentage,
2299        })
2300    }
2301
2302    /// Update checkpoint status.
2303    ///
2304    /// # Errors
2305    ///
2306    /// Returns an error if the database write fails.
2307    pub fn update_checkpoint_status(&self, namespace_id: &str, session_id: &str, status: &str) -> Result<()> {
2308        let connection = self
2309            .connection
2310            .lock()
2311            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2312        let now = current_timestamp().cast_signed();
2313        connection
2314            .execute(
2315                "UPDATE sync_checkpoints SET status = ?1, last_updated_at = ?2
2316             WHERE namespace_id = ?3 AND session_id = ?4",
2317                params![status, now, namespace_id, session_id],
2318            )
2319            .map_err(|error| SyncwebError::operation("failed to update checkpoint status", error))?;
2320        drop(connection);
2321        Ok(())
2322    }
2323
2324    /// Find the most recent unfinished checkpoint for a namespace.
2325    ///
2326    /// # Errors
2327    ///
2328    /// Returns an error if the database query fails.
2329    pub fn find_unfinished_checkpoint(&self, namespace_id: &str) -> Result<Option<String>> {
2330        let connection = self
2331            .connection
2332            .lock()
2333            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2334        let result: Option<String> = connection
2335            .query_row(
2336                "SELECT session_id FROM sync_checkpoints
2337             WHERE namespace_id = ?1 AND status IN ('running', 'pending')
2338             ORDER BY started_at DESC LIMIT 1",
2339                params![namespace_id],
2340                |row| row.get(0),
2341            )
2342            .optional()
2343            .map_err(|error| SyncwebError::operation("failed to find unfinished checkpoint", error))?;
2344        drop(connection);
2345        Ok(result)
2346    }
2347
2348    /// Delete a sync checkpoint and its entries.
2349    ///
2350    /// # Errors
2351    ///
2352    /// Returns an error if the database write fails.
2353    pub fn delete_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
2354        let connection = self
2355            .connection
2356            .lock()
2357            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2358        connection
2359            .execute(
2360                "DELETE FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2",
2361                params![namespace_id, session_id],
2362            )
2363            .map_err(|error| SyncwebError::operation("failed to delete sync entry progress", error))?;
2364        connection
2365            .execute(
2366                "DELETE FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2",
2367                params![namespace_id, session_id],
2368            )
2369            .map_err(|error| SyncwebError::operation("failed to delete sync checkpoint", error))?;
2370        drop(connection);
2371        Ok(())
2372    }
2373
2374    /// Clean up stale (dangling) checkpoints older than the given number of seconds.
2375    ///
2376    /// # Errors
2377    ///
2378    /// Returns an error if the database write fails.
2379    pub fn clean_stale_checkpoints(&self, max_age_secs: i64) -> Result<usize> {
2380        let connection = self
2381            .connection
2382            .lock()
2383            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2384        let cutoff = current_timestamp().cast_signed().saturating_sub(max_age_secs);
2385        let deleted = connection
2386            .execute(
2387                "DELETE FROM sync_checkpoints WHERE last_updated_at < ?1 AND status NOT IN ('completed')",
2388                params![cutoff],
2389            )
2390            .map_err(|error| SyncwebError::operation("failed to clean stale checkpoints", error))?;
2391        drop(connection);
2392        Ok(deleted)
2393    }
2394
2395    // ------------------------------------------------------------------
2396    // Database maintenance methods
2397    // ------------------------------------------------------------------
2398
2399    /// Run VACUUM to reclaim space.
2400    ///
2401    /// # Errors
2402    ///
2403    /// Returns an error if the database operation fails.
2404    pub fn vacuum(&self) -> Result<()> {
2405        let connection = self
2406            .connection
2407            .lock()
2408            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2409        connection
2410            .execute_batch("VACUUM")
2411            .map_err(|error| SyncwebError::operation("vacuum failed", error))?;
2412        drop(connection);
2413        Ok(())
2414    }
2415
2416    /// Return estimated database size in bytes.
2417    ///
2418    /// # Errors
2419    ///
2420    /// Returns an error if the file metadata cannot be read.
2421    pub fn size_on_disk(&self) -> Result<u64> {
2422        let connection = self
2423            .connection
2424            .lock()
2425            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2426        let db_path: String = connection
2427            .query_row("PRAGMA database_list", [], |row| row.get(2))
2428            .map_err(|error| SyncwebError::operation("failed to query database path", error))?;
2429        drop(connection);
2430        std::fs::metadata(&db_path)
2431            .map(|meta| meta.len())
2432            .map_err(|error| SyncwebError::operation("failed to get database file size", error))
2433    }
2434
2435    /// Return the freelist page count (indicates whether VACUUM would help).
2436    ///
2437    /// # Errors
2438    ///
2439    /// Returns an error if the database operation fails.
2440    pub fn freelist_count(&self) -> Result<i64> {
2441        let connection = self
2442            .connection
2443            .lock()
2444            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2445        let count: i64 = connection
2446            .pragma_query_value(None, "freelist_count", |row| row.get(0))
2447            .map_err(|error| SyncwebError::operation("failed to query freelist count", error))?;
2448        drop(connection);
2449        Ok(count)
2450    }
2451
2452    /// Run `integrity_check` PRAGMA. Returns list of errors (empty = healthy).
2453    ///
2454    /// # Errors
2455    ///
2456    /// Returns an error if the database operation fails.
2457    pub fn check_integrity(&self) -> Result<Vec<String>> {
2458        let connection = self
2459            .connection
2460            .lock()
2461            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2462        let mut stmt = connection
2463            .prepare("PRAGMA integrity_check")
2464            .map_err(|error| SyncwebError::operation("failed to prepare integrity check", error))?;
2465        let errors: Vec<String> = stmt
2466            .query_map([], |row| row.get::<_, String>(0))
2467            .map_err(|error| SyncwebError::operation("failed to run integrity check", error))?
2468            .filter_map(std::result::Result::ok)
2469            .filter(|s| s != "ok")
2470            .collect();
2471        drop(stmt);
2472        drop(connection);
2473        Ok(errors)
2474    }
2475}
2476
2477impl crate::storage::Vacuumable for NodeDatabase {
2478    fn vacuum(&self) -> Result<()> {
2479        self.vacuum()
2480    }
2481
2482    fn freelist_count(&self) -> Result<i64> {
2483        self.freelist_count()
2484    }
2485}
2486
2487#[cfg(test)]
2488mod tests {
2489    use super::*;
2490    use std::sync::Arc;
2491
2492    fn test_db() -> NodeDatabase {
2493        let conn = Connection::open_in_memory().unwrap();
2494        let db = NodeDatabase {
2495            connection: Arc::new(Mutex::new(conn)),
2496        };
2497        db.init_schema().unwrap();
2498        db
2499    }
2500
2501    fn make_public_key(seed: u8) -> PublicKey {
2502        iroh::SecretKey::from_bytes(&[seed; 32]).public()
2503    }
2504
2505    #[test]
2506    fn test_is_peer_network_member_true() {
2507        let db = test_db();
2508        let member_key = make_public_key(1);
2509        let owner = make_public_key(2);
2510
2511        let mut network = Network::new("test", owner, crate::net::network::NetworkOptions::default());
2512        network.members.insert(member_key);
2513        db.create_network(&network).unwrap();
2514
2515        assert!(db.is_peer_network_member(&member_key.to_string()).unwrap());
2516    }
2517
2518    #[test]
2519    fn test_is_peer_network_member_false() {
2520        let db = test_db();
2521        assert!(!db.is_peer_network_member("unknown-peer").unwrap());
2522    }
2523
2524    #[test]
2525    fn test_transfer_roots_and_jobs_survive_lifecycle_updates() {
2526        let db = test_db();
2527        db.upsert_storage_root(&StorageRootRecord {
2528            id: "root".to_owned(),
2529            path: PathBuf::from("/tmp/syncweb-root"),
2530            min_free: 10,
2531            enabled: true,
2532        })
2533        .unwrap();
2534        let hash = [7_u8; 32];
2535        let entry_key = b"dir/file.bin";
2536        let job_id = db
2537            .enqueue_transfer_job(&NewTransferJob {
2538                namespace_id: "namespace",
2539                entry_key,
2540                hash: &hash,
2541                size: 25,
2542                root_id: Some("root"),
2543                destination: Some(Path::new("/tmp/syncweb-root/namespace/dir/file.bin")),
2544            })
2545            .unwrap();
2546
2547        assert_eq!(db.reserved_transfer_bytes("root").unwrap(), 25);
2548        let jobs = db.list_transfer_jobs(Some("namespace"), Some("queued")).unwrap();
2549        assert_eq!(jobs.len(), 1);
2550        let job = jobs.first().expect("queued transfer job should be listed");
2551        assert_eq!(job.id, job_id);
2552        assert_eq!(job.hash, hash);
2553        assert_eq!(
2554            job.destination.as_deref(),
2555            Some(Path::new("/tmp/syncweb-root/namespace/dir/file.bin"))
2556        );
2557
2558        db.update_transfer_job_progress(&job_id, 12, 2, Some(4), 1).unwrap();
2559        db.update_transfer_job_state(&job_id, "materializing", None).unwrap();
2560        assert_eq!(db.recover_transfer_jobs().unwrap(), 1);
2561        let recovered = db.list_transfer_jobs(None, Some("queued")).unwrap();
2562        let recovered_job = recovered.first().expect("recovered transfer job should be listed");
2563        assert_eq!(recovered_job.bytes_transferred, 12);
2564        assert_eq!(recovered_job.retries, 1);
2565    }
2566
2567    #[test]
2568    fn test_assign_transfer_job_requires_queued_job() {
2569        let db = test_db();
2570        db.upsert_storage_root(&StorageRootRecord {
2571            id: "root".to_owned(),
2572            path: PathBuf::from("/tmp/syncweb-root"),
2573            min_free: 0,
2574            enabled: true,
2575        })
2576        .unwrap();
2577        let hash = [3_u8; 32];
2578        let job_id = db
2579            .enqueue_transfer_job(&NewTransferJob {
2580                namespace_id: "namespace",
2581                entry_key: b"file",
2582                hash: &hash,
2583                size: 1,
2584                root_id: None,
2585                destination: None,
2586            })
2587            .unwrap();
2588        db.assign_transfer_job(&job_id, "root", Path::new("/tmp/file")).unwrap();
2589        let jobs = db.list_transfer_jobs(None, None).unwrap();
2590        let job = jobs.first().expect("assigned transfer job should be listed");
2591        assert_eq!(job.root_id.as_deref(), Some("root"));
2592    }
2593}