Skip to main content

syncweb_core/storage/
node_db.rs

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