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