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