Skip to main content

syncweb_core/storage/
node_db.rs

1use std::{
2    collections::{BTreeMap, HashMap, HashSet},
3    path::{Path, PathBuf},
4    str::FromStr,
5    sync::{Arc, Mutex},
6};
7
8use iroh::PublicKey;
9use iroh_blobs::Hash;
10use iroh_docs::NamespaceId;
11use rusqlite::{Connection, OptionalExtension, params};
12use 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, network_topic, 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)]
42pub struct NodeDatabase {
43    connection: Arc<Mutex<Connection>>,
44}
45
46impl NodeDatabase {
47    /// Open (or create) the node database.
48    ///
49    /// # Errors
50    ///
51    /// Returns an error if the database cannot be opened or initialized.
52    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
53        let db_path = path.as_ref();
54        if let Some(parent) = db_path.parent() {
55            std::fs::create_dir_all(parent)
56                .map_err(|error| SyncwebError::operation("failed to create node db directory", error))?;
57        }
58        let connection = Connection::open(db_path)
59            .map_err(|error| SyncwebError::operation("failed to open node database", error))?;
60        let db = Self {
61            connection: Arc::new(Mutex::new(connection)),
62        };
63        db.init_schema()?;
64        Ok(db)
65    }
66
67    /// Returns the SQL statements to create the database schema.
68    const fn create_schema_sql() -> &'static str {
69        "CREATE TABLE IF NOT EXISTS schema_version (
70            key TEXT PRIMARY KEY,
71            value TEXT NOT NULL
72        );
73        CREATE TABLE IF NOT EXISTS daemon_lifecycle (
74            id INTEGER PRIMARY KEY CHECK(id = 1),
75            pid INTEGER NOT NULL,
76            node_id TEXT NOT NULL,
77            started_at INTEGER NOT NULL,
78            status TEXT NOT NULL CHECK(status IN ('starting','running','stopping','stopped')),
79            data_dir TEXT NOT NULL,
80            updated_at INTEGER NOT NULL
81        );
82        CREATE TABLE IF NOT EXISTS daemon_status (
83            id INTEGER PRIMARY KEY CHECK(id = 1),
84            pid INTEGER NOT NULL,
85            node_id TEXT NOT NULL,
86            started_at INTEGER NOT NULL,
87            uptime_seconds INTEGER NOT NULL,
88            upload_total INTEGER NOT NULL DEFAULT 0,
89            download_total INTEGER NOT NULL DEFAULT 0,
90            upload_rate INTEGER NOT NULL DEFAULT 0,
91            download_rate INTEGER NOT NULL DEFAULT 0,
92            has_schedule INTEGER NOT NULL DEFAULT 0,
93            in_active_window INTEGER NOT NULL DEFAULT 0,
94            next_window_start INTEGER,
95            rayon_threads INTEGER NOT NULL,
96            updated_at INTEGER NOT NULL
97        );
98        CREATE TABLE IF NOT EXISTS folder_status_reports (
99            namespace_id TEXT NOT NULL,
100            path TEXT NOT NULL,
101            session_active INTEGER NOT NULL DEFAULT 0,
102            last_sync_at INTEGER,
103            entries_synced INTEGER NOT NULL DEFAULT 0,
104            errors_json TEXT NOT NULL DEFAULT '[]',
105            updated_at INTEGER NOT NULL,
106            PRIMARY KEY(namespace_id)
107        );
108        CREATE TABLE IF NOT EXISTS networks (
109            id TEXT PRIMARY KEY,
110            name TEXT NOT NULL UNIQUE,
111            label TEXT NOT NULL DEFAULT '',
112            owner TEXT NOT NULL,
113            shared_secret BLOB,
114            created_at INTEGER NOT NULL
115        );
116        CREATE TABLE IF NOT EXISTS network_members (
117            network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
118            member TEXT NOT NULL,
119            PRIMARY KEY(network_id, member)
120        );
121        CREATE TABLE IF NOT EXISTS network_folders (
122            network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
123            namespace_id TEXT NOT NULL,
124            PRIMARY KEY(network_id, namespace_id)
125        );
126        CREATE TABLE IF NOT EXISTS installed_collections (
127            collection_id TEXT PRIMARY KEY,
128            manifest_hash TEXT NOT NULL,
129            current_version TEXT NOT NULL,
130            installed_at INTEGER NOT NULL
131        );
132        CREATE TABLE IF NOT EXISTS collection_versions (
133            collection_id TEXT NOT NULL REFERENCES installed_collections(collection_id) ON DELETE CASCADE,
134            version TEXT NOT NULL,
135            install_path TEXT NOT NULL,
136            PRIMARY KEY(collection_id, version)
137        );
138        CREATE TABLE IF NOT EXISTS app_config (
139            key TEXT PRIMARY KEY,
140            value TEXT NOT NULL,
141            updated_at INTEGER NOT NULL
142        );
143        CREATE TABLE IF NOT EXISTS filter_rules (
144            id INTEGER PRIMARY KEY AUTOINCREMENT,
145            namespace_id TEXT,
146            rule_type TEXT NOT NULL,
147            pattern TEXT NOT NULL,
148            priority INTEGER NOT NULL DEFAULT 0,
149            updated_at INTEGER NOT NULL
150        );
151        CREATE TABLE IF NOT EXISTS public_subscriptions (
152            hash TEXT PRIMARY KEY,
153            size INTEGER NOT NULL,
154            subscribed_at INTEGER NOT NULL
155        );
156        CREATE TABLE IF NOT EXISTS workspace_manifests (
157            source_path TEXT NOT NULL,
158            manifest_bytes BLOB NOT NULL,
159            collection_id TEXT NOT NULL,
160            updated_at INTEGER NOT NULL,
161            PRIMARY KEY(source_path)
162        );
163        CREATE TABLE IF NOT EXISTS blob_folders (
164            content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
165            namespace_id TEXT NOT NULL,
166            entry_key BLOB NOT NULL,
167            added_at INTEGER NOT NULL,
168            PRIMARY KEY(content_hash, namespace_id, entry_key)
169        );
170        CREATE INDEX IF NOT EXISTS idx_blob_folders_hash ON blob_folders(content_hash);
171        CREATE TABLE IF NOT EXISTS sync_checkpoints (
172            namespace_id TEXT NOT NULL,
173            session_id TEXT NOT NULL,
174            total_entries INTEGER NOT NULL DEFAULT 0,
175            processed_entries INTEGER NOT NULL DEFAULT 0,
176            failed_entries INTEGER NOT NULL DEFAULT 0,
177            bytes_total INTEGER,
178            bytes_transferred INTEGER NOT NULL DEFAULT 0,
179            started_at INTEGER NOT NULL,
180            last_updated_at INTEGER NOT NULL,
181            status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','completed','failed','cancelled')),
182            PRIMARY KEY(namespace_id, session_id)
183        );
184        CREATE TABLE IF NOT EXISTS sync_entry_progress (
185            namespace_id TEXT NOT NULL,
186            session_id TEXT NOT NULL,
187            entry_key BLOB NOT NULL,
188            hash BLOB NOT NULL,
189            size INTEGER NOT NULL,
190            status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','downloading','completed','failed','skipped')),
191            retries INTEGER NOT NULL DEFAULT 0,
192            error_message TEXT,
193            updated_at INTEGER NOT NULL,
194            PRIMARY KEY(namespace_id, session_id, entry_key),
195            FOREIGN KEY(namespace_id, session_id) REFERENCES sync_checkpoints(namespace_id, session_id) ON DELETE CASCADE
196        );"
197    }
198
199    /// Initialize the database schema, creating tables and indexes if they don't exist.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if the database mutex is poisoned or if a SQL operation fails.
204    pub fn init_schema(&self) -> Result<()> {
205        let connection = self
206            .connection
207            .lock()
208            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
209        connection
210            .busy_timeout(std::time::Duration::from_secs(5))
211            .map_err(|error| SyncwebError::operation("failed to configure node database", error))?;
212        connection
213            .pragma_update(None, "journal_mode", "WAL")
214            .map_err(|error| SyncwebError::operation("failed to set WAL mode", error))?;
215        connection
216            .pragma_update(None, "foreign_keys", "ON")
217            .map_err(|error| SyncwebError::operation("failed to enable foreign keys", error))?;
218        connection
219            .execute_batch(Self::create_schema_sql())
220            .map_err(|error| SyncwebError::operation("failed to initialize node database schema", error))?;
221        if let Err(error) = connection.execute_batch("ALTER TABLE networks ADD COLUMN doc_ticket TEXT") {
222            tracing::debug!(%error, "migration: add doc_ticket (may already exist)");
223        }
224
225        connection
226            .execute(
227                "INSERT INTO schema_version(key, value) VALUES ('version', '1')
228             ON CONFLICT(key) DO NOTHING",
229                [],
230            )
231            .map_err(|error| SyncwebError::operation("failed to seed schema version", error))?;
232        drop(connection);
233        Ok(())
234    }
235
236    /// Persist the daemon lifecycle state.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error if the database cannot be written.
241    pub fn save_lifecycle(&self, state: &DaemonState) -> Result<()> {
242        let connection = self
243            .connection
244            .lock()
245            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
246        let status = match state.status {
247            DaemonStatus::Starting => "starting",
248            DaemonStatus::Running => "running",
249            DaemonStatus::Stopping => "stopping",
250            DaemonStatus::Stopped => "stopped",
251        };
252        let now = current_timestamp();
253        connection
254            .execute(
255                "INSERT INTO daemon_lifecycle(id, pid, node_id, started_at, status, data_dir, updated_at)
256             VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)
257             ON CONFLICT(id) DO UPDATE SET
258                pid = excluded.pid,
259                node_id = excluded.node_id,
260                started_at = excluded.started_at,
261                status = excluded.status,
262                data_dir = excluded.data_dir,
263                updated_at = excluded.updated_at",
264                params![
265                    i64::from(state.pid),
266                    state.node_id,
267                    state.started_at.cast_signed(),
268                    status,
269                    state.data_dir.to_string_lossy().as_ref(),
270                    now.cast_signed(),
271                ],
272            )
273            .map_err(|error| SyncwebError::operation("failed to save daemon lifecycle", error))?;
274        drop(connection);
275        Ok(())
276    }
277
278    /// Load the daemon lifecycle state, returning `None` if absent.
279    ///
280    /// # Errors
281    ///
282    /// Returns an error if the database cannot be read.
283    pub fn load_lifecycle(&self) -> Result<Option<DaemonState>> {
284        let connection = self
285            .connection
286            .lock()
287            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
288        let result = connection
289            .query_row(
290                "SELECT pid, node_id, started_at, status, data_dir FROM daemon_lifecycle WHERE id = 1",
291                [],
292                |row| {
293                    let pid: i64 = row.get(0)?;
294                    let node_id: String = row.get(1)?;
295                    let started_at: i64 = row.get(2)?;
296                    let status_str: String = row.get(3)?;
297                    let data_dir: String = row.get(4)?;
298                    let status = match status_str.as_str() {
299                        "starting" => DaemonStatus::Starting,
300                        "running" => DaemonStatus::Running,
301                        "stopping" => DaemonStatus::Stopping,
302                        "stopped" => DaemonStatus::Stopped,
303                        _ => {
304                            return Err(rusqlite::Error::InvalidColumnType(
305                                3,
306                                "status".to_string(),
307                                rusqlite::types::Type::Text,
308                            ));
309                        }
310                    };
311                    Ok(DaemonState::new(
312                        u32::try_from(pid).unwrap_or_default(),
313                        node_id,
314                        started_at.cast_unsigned(),
315                        data_dir,
316                        status,
317                    ))
318                },
319            )
320            .optional()
321            .map_err(|error| SyncwebError::operation("failed to load daemon lifecycle", error))?;
322        drop(connection);
323        Ok(result)
324    }
325
326    /// Remove the daemon lifecycle state.
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if the database cannot be written.
331    pub fn remove_lifecycle(&self) -> Result<()> {
332        let connection = self
333            .connection
334            .lock()
335            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
336        connection
337            .execute("DELETE FROM daemon_lifecycle WHERE id = 1", [])
338            .map_err(|error| SyncwebError::operation("failed to remove daemon lifecycle", error))?;
339        drop(connection);
340        Ok(())
341    }
342
343    /// Persist the daemon status report.
344    ///
345    /// # Errors
346    ///
347    /// Returns an error if the database cannot be written.
348    pub fn save_status(&self, report: &DaemonStatusReport) -> Result<()> {
349        let connection = self
350            .connection
351            .lock()
352            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
353        let now = current_timestamp().cast_signed();
354        let has_schedule = report.schedule.is_some();
355        connection
356            .execute(
357                "INSERT INTO daemon_status(id, pid, node_id, started_at, uptime_seconds,
358                upload_total, download_total, upload_rate, download_rate,
359                has_schedule, in_active_window, next_window_start, rayon_threads, updated_at)
360             VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
361             ON CONFLICT(id) DO UPDATE SET
362                pid = excluded.pid,
363                node_id = excluded.node_id,
364                started_at = excluded.started_at,
365                uptime_seconds = excluded.uptime_seconds,
366                upload_total = excluded.upload_total,
367                download_total = excluded.download_total,
368                upload_rate = excluded.upload_rate,
369                download_rate = excluded.download_rate,
370                has_schedule = excluded.has_schedule,
371                in_active_window = excluded.in_active_window,
372                next_window_start = excluded.next_window_start,
373                rayon_threads = excluded.rayon_threads,
374                updated_at = excluded.updated_at",
375                params![
376                    i64::from(report.pid),
377                    report.node_id,
378                    report.started_at.cast_signed(),
379                    report.uptime_seconds.cast_signed(),
380                    report.bandwidth.upload_total.cast_signed(),
381                    report.bandwidth.download_total.cast_signed(),
382                    report.bandwidth.upload_rate.cast_signed(),
383                    report.bandwidth.download_rate.cast_signed(),
384                    i64::from(has_schedule),
385                    report.schedule.map_or(0, |s| i64::from(s.in_active_window)),
386                    report.schedule.and_then(|s| s.next_window_start.map(u64::cast_signed)),
387                    i64::try_from(report.rayon_threads).unwrap_or_default(),
388                    now,
389                ],
390            )
391            .map_err(|error| SyncwebError::operation("failed to save daemon status", error))?;
392
393        for folder in &report.folders {
394            let errors_json = serde_json::to_string(&folder.errors).unwrap_or_else(|_| "[]".to_string());
395            connection
396                .execute(
397                    "INSERT INTO folder_status_reports(namespace_id, path, session_active, last_sync_at,
398                    entries_synced, errors_json, updated_at)
399                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
400                 ON CONFLICT(namespace_id) DO UPDATE SET
401                    path = excluded.path,
402                    session_active = excluded.session_active,
403                    last_sync_at = excluded.last_sync_at,
404                    entries_synced = excluded.entries_synced,
405                    errors_json = excluded.errors_json,
406                    updated_at = excluded.updated_at",
407                    params![
408                        folder.namespace,
409                        folder.path.to_string_lossy().as_ref(),
410                        i64::from(folder.session_active),
411                        folder.last_sync_at.map(u64::cast_signed),
412                        folder.entries_synced.cast_signed(),
413                        errors_json,
414                        now,
415                    ],
416                )
417                .map_err(|error| SyncwebError::operation("failed to save folder status", error))?;
418        }
419        drop(connection);
420        Ok(())
421    }
422
423    /// Load the daemon status report, returning `None` if absent.
424    ///
425    /// # Errors
426    ///
427    /// Returns an error if the database cannot be read.
428    pub fn load_status(&self) -> Result<Option<DaemonStatusReport>> {
429        let connection = self
430            .connection
431            .lock()
432            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
433        let result: Option<DaemonStatusReport> = connection
434            .query_row(
435                "SELECT pid, node_id, started_at, uptime_seconds,
436                upload_total, download_total, upload_rate, download_rate,
437                has_schedule, in_active_window, next_window_start, rayon_threads
438             FROM daemon_status WHERE id = 1",
439                [],
440                |row| {
441                    let pid: i64 = row.get(0)?;
442                    let node_id: String = row.get(1)?;
443                    let started_at: i64 = row.get(2)?;
444                    let uptime_seconds: i64 = row.get(3)?;
445                    let upload_total: i64 = row.get(4)?;
446                    let download_total: i64 = row.get(5)?;
447                    let upload_rate: i64 = row.get(6)?;
448                    let download_rate: i64 = row.get(7)?;
449                    let has_schedule: i64 = row.get(8)?;
450                    let in_active_window: i64 = row.get(9)?;
451                    let next_window_start: Option<i64> = row.get(10)?;
452                    let rayon_threads: i64 = row.get(11)?;
453
454                    let schedule = (has_schedule != 0).then(|| ScheduleStatus {
455                        in_active_window: in_active_window != 0,
456                        next_window_start: next_window_start.map(i64::cast_unsigned),
457                    });
458
459                    Ok(DaemonStatusReport {
460                        pid: u32::try_from(pid).unwrap_or_default(),
461                        node_id,
462                        started_at: started_at.cast_unsigned(),
463                        uptime_seconds: uptime_seconds.cast_unsigned(),
464                        folders: Vec::new(),
465                        bandwidth: BandwidthSnapshot {
466                            upload_total: upload_total.cast_unsigned(),
467                            download_total: download_total.cast_unsigned(),
468                            upload_rate: upload_rate.cast_unsigned(),
469                            download_rate: download_rate.cast_unsigned(),
470                        },
471                        schedule,
472                        rayon_threads: usize::try_from(rayon_threads).unwrap_or_default(),
473                    })
474                },
475            )
476            .optional()
477            .map_err(|error| SyncwebError::operation("failed to load daemon status", error))?;
478
479        if let Some(mut report) = result {
480            let mut folder_stmt = connection
481                .prepare(
482                    "SELECT namespace_id, path, session_active, last_sync_at, entries_synced, errors_json
483                 FROM folder_status_reports",
484                )
485                .map_err(|error| SyncwebError::operation("failed to prepare folder status query", error))?;
486            let folders: Vec<FolderStatusReport> = folder_stmt
487                .query_map([], |row| {
488                    let namespace: String = row.get(0)?;
489                    let path: String = row.get(1)?;
490                    let session_active: i64 = row.get(2)?;
491                    let last_sync_at: Option<i64> = row.get(3)?;
492                    let entries_synced: i64 = row.get(4)?;
493                    let errors_json: String = row.get(5)?;
494                    let errors: Vec<String> = serde_json::from_str(&errors_json).unwrap_or_default();
495                    Ok(FolderStatusReport::new(
496                        namespace,
497                        path,
498                        session_active != 0,
499                        last_sync_at.map(i64::cast_unsigned),
500                        entries_synced.cast_unsigned(),
501                        errors,
502                    ))
503                })
504                .map_err(|error| SyncwebError::operation("failed to query folder statuses", error))?
505                .collect::<std::result::Result<Vec<_>, _>>()
506                .map_err(|error| SyncwebError::operation("failed to read folder status rows", error))?;
507            report.folders = folders;
508            drop(folder_stmt);
509            drop(connection);
510            Ok(Some(report))
511        } else {
512            drop(connection);
513            Ok(None)
514        }
515    }
516
517    /// Remove all daemon status data.
518    ///
519    /// # Errors
520    ///
521    /// Returns an error if the database cannot be written.
522    pub fn remove_status(&self) -> Result<()> {
523        let connection = self
524            .connection
525            .lock()
526            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
527        connection
528            .execute("DELETE FROM daemon_status WHERE id = 1", [])
529            .map_err(|error| SyncwebError::operation("failed to remove daemon status", error))?;
530        connection
531            .execute("DELETE FROM folder_status_reports", [])
532            .map_err(|error| SyncwebError::operation("failed to remove folder status reports", error))?;
533        drop(connection);
534        Ok(())
535    }
536
537    /// Create a network entry in the database.
538    ///
539    /// # Errors
540    ///
541    /// Returns an error if the database cannot be written.
542    pub fn create_network(&self, network: &Network) -> Result<()> {
543        let connection = self
544            .connection
545            .lock()
546            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
547        let now = current_timestamp().cast_signed();
548        connection.execute(
549            "INSERT INTO networks(id, name, label, owner, shared_secret, created_at, doc_ticket) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
550            params![
551                network.id.to_string(),
552                network.name,
553                network.label,
554                network.owner.to_string(),
555                network.shared_secret.as_ref().map(|s| s.to_vec()),
556                now,
557                network.doc_ticket,
558            ],
559        ).map_err(|error| SyncwebError::operation("failed to create network", error))?;
560        for member in &network.members {
561            connection
562                .execute(
563                    "INSERT INTO network_members(network_id, member) VALUES (?1, ?2)",
564                    params![network.id.to_string(), member.to_string()],
565                )
566                .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
567        }
568        for folder in &network.folders {
569            connection
570                .execute(
571                    "INSERT INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
572                    params![network.id.to_string(), folder.to_string()],
573                )
574                .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
575        }
576        drop(connection);
577        Ok(())
578    }
579
580    /// Delete a network from the database.
581    ///
582    /// # Errors
583    ///
584    /// Returns an error if the network is not found or the database cannot be written.
585    pub fn delete_network(&self, id: NetworkId) -> Result<()> {
586        let connection = self
587            .connection
588            .lock()
589            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
590        let affected = connection
591            .execute("DELETE FROM networks WHERE id = ?1", params![id.to_string()])
592            .map_err(|error| SyncwebError::operation("failed to delete network", error))?;
593        if affected == 0 {
594            drop(connection);
595            return Err(SyncwebError::FolderNotFound(format!("network {id}")));
596        }
597        drop(connection);
598        Ok(())
599    }
600
601    /// Add a member to a network.
602    ///
603    /// # Errors
604    ///
605    /// Returns an error if the database cannot be written.
606    pub fn add_member(&self, network_id: NetworkId, member: PublicKey) -> Result<()> {
607        let connection = self
608            .connection
609            .lock()
610            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
611        connection
612            .execute(
613                "INSERT OR IGNORE INTO network_members(network_id, member) VALUES (?1, ?2)",
614                params![network_id.to_string(), member.to_string()],
615            )
616            .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
617        drop(connection);
618        Ok(())
619    }
620
621    /// Remove a member from a network.
622    ///
623    /// # Errors
624    ///
625    /// Returns an error if the database cannot be written.
626    pub fn remove_member(&self, network_id: NetworkId, member: &PublicKey) -> Result<bool> {
627        let connection = self
628            .connection
629            .lock()
630            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
631        let affected = connection
632            .execute(
633                "DELETE FROM network_members WHERE network_id = ?1 AND member = ?2",
634                params![network_id.to_string(), member.to_string()],
635            )
636            .map_err(|error| SyncwebError::operation("failed to remove network member", error))?;
637        drop(connection);
638        Ok(affected > 0)
639    }
640
641    /// Add a folder to a network.
642    ///
643    /// # Errors
644    ///
645    /// Returns an error if the database cannot be written.
646    pub fn add_folder_to_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
647        let connection = self
648            .connection
649            .lock()
650            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
651        connection
652            .execute(
653                "INSERT OR IGNORE INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
654                params![network_id.to_string(), namespace_id.to_string()],
655            )
656            .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
657        drop(connection);
658        Ok(())
659    }
660
661    /// Remove a folder from a network.
662    ///
663    /// # Errors
664    ///
665    /// Returns an error if the database cannot be written.
666    pub fn remove_folder_from_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
667        let connection = self
668            .connection
669            .lock()
670            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
671        connection
672            .execute(
673                "DELETE FROM network_folders WHERE network_id = ?1 AND namespace_id = ?2",
674                params![network_id.to_string(), namespace_id.to_string()],
675            )
676            .map_err(|error| SyncwebError::operation("failed to remove network folder", error))?;
677        drop(connection);
678        Ok(())
679    }
680
681    /// List all networks from the database.
682    ///
683    /// # Errors
684    ///
685    /// Returns an error if the database cannot be read.
686    pub fn list_networks(&self) -> Result<Vec<Network>> {
687        let connection = self
688            .connection
689            .lock()
690            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
691        let mut stmt = connection
692            .prepare("SELECT id, name, label, owner, shared_secret, doc_ticket FROM networks ORDER BY name")
693            .map_err(|error| SyncwebError::operation("failed to prepare network query", error))?;
694        let networks = stmt
695            .query_map([], |row| {
696                Ok((
697                    row.get::<_, String>(0)?,
698                    row.get::<_, String>(1)?,
699                    row.get::<_, String>(2)?,
700                    row.get::<_, String>(3)?,
701                    row.get::<_, Option<Vec<u8>>>(4)?,
702                    row.get::<_, Option<String>>(5)?,
703                ))
704            })
705            .map_err(|error| SyncwebError::operation("failed to query networks", error))?
706            .collect::<std::result::Result<Vec<_>, _>>()
707            .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
708
709        let mut member_stmt = connection
710            .prepare("SELECT member FROM network_members WHERE network_id = ?1")
711            .map_err(|error| SyncwebError::operation("failed to prepare member query", error))?;
712        let mut folder_stmt = connection
713            .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
714            .map_err(|error| SyncwebError::operation("failed to prepare folder query", error))?;
715
716        let mut result = Vec::new();
717        for (id_str, name, label, owner_str, shared_secret_bytes, doc_ticket) in networks {
718            let network_id = NetworkId::from_str(&id_str)
719                .map_err(|error| SyncwebError::operation("invalid network ID in database", error))?;
720            let owner = parse_public_key(&owner_str)?;
721            let shared_secret = shared_secret_bytes
722                .map(|bytes| -> Result<[u8; 32]> {
723                    bytes.try_into().map_err(|_bytes: Vec<u8>| {
724                        SyncwebError::operation("invalid shared secret length in database", "expected 32 bytes")
725                    })
726                })
727                .transpose()?;
728
729            let members: HashSet<PublicKey> = member_stmt
730                .query_map(params![id_str], |row| row.get::<_, String>(0))
731                .map_err(|error| SyncwebError::operation("failed to query members", error))?
732                .collect::<std::result::Result<Vec<_>, _>>()
733                .map_err(|error| SyncwebError::operation("failed to read member rows", error))?
734                .into_iter()
735                .map(|s| parse_public_key(&s))
736                .collect::<Result<HashSet<_>>>()?;
737
738            let folders: HashSet<NamespaceId> = folder_stmt
739                .query_map(params![id_str], |row| row.get::<_, String>(0))
740                .map_err(|error| SyncwebError::operation("failed to query folders", error))?
741                .collect::<std::result::Result<Vec<_>, _>>()
742                .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?
743                .into_iter()
744                .map(|s| s.parse())
745                .collect::<std::result::Result<HashSet<_>, _>>()
746                .map_err(|error| SyncwebError::operation("invalid folder namespace in database", error))?;
747
748            result.push(Network {
749                id: network_id,
750                name,
751                label,
752                topic: network_topic(network_id),
753                owner,
754                members,
755                folders,
756                shared_secret,
757                doc_ticket,
758            });
759        }
760        drop(stmt);
761        drop(member_stmt);
762        drop(folder_stmt);
763        drop(connection);
764        Ok(result)
765    }
766
767    /// Get the installed collection state.
768    ///
769    /// # Errors
770    ///
771    /// Returns an error if the database cannot be read.
772    pub fn get_collection_state(&self) -> Result<CollectionState> {
773        let connection = self
774            .connection
775            .lock()
776            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
777        let mut stmt = connection
778            .prepare("SELECT collection_id, manifest_hash, current_version FROM installed_collections")
779            .map_err(|error| SyncwebError::operation("failed to prepare collection query", error))?;
780        let collections: Vec<(String, String, String)> = stmt
781            .query_map([], |row| {
782                Ok((
783                    row.get::<_, String>(0)?,
784                    row.get::<_, String>(1)?,
785                    row.get::<_, String>(2)?,
786                ))
787            })
788            .map_err(|error| SyncwebError::operation("failed to query collections", error))?
789            .collect::<std::result::Result<Vec<_>, _>>()
790            .map_err(|error| SyncwebError::operation("failed to read collection rows", error))?;
791
792        let mut version_stmt = connection
793            .prepare("SELECT version, install_path FROM collection_versions WHERE collection_id = ?1")
794            .map_err(|error| SyncwebError::operation("failed to prepare version query", error))?;
795
796        let mut installed = BTreeMap::new();
797        for (id_str, manifest_hash, current_version) in collections {
798            let collection_id = Uuid::from_str(&id_str)
799                .map_err(|error| SyncwebError::operation("invalid collection ID in database", error))?;
800            let manifest = iroh_blobs::Hash::from_str(&manifest_hash)
801                .map_err(|error| SyncwebError::operation("invalid manifest hash in database", error))?;
802
803            let versions: BTreeMap<String, PathBuf> = version_stmt
804                .query_map(params![id_str], |row| {
805                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
806                })
807                .map_err(|error| SyncwebError::operation("failed to query versions", error))?
808                .collect::<std::result::Result<Vec<_>, _>>()
809                .map_err(|error| SyncwebError::operation("failed to read version rows", error))?
810                .into_iter()
811                .map(|(v, p)| (v, PathBuf::from(p)))
812                .collect();
813
814            installed.insert(
815                collection_id,
816                InstalledCollection {
817                    manifest,
818                    versions,
819                    current: current_version,
820                },
821            );
822        }
823        drop(stmt);
824        drop(version_stmt);
825        drop(connection);
826        Ok(CollectionState { installed })
827    }
828
829    /// Install a collection version in the database.
830    ///
831    /// # Errors
832    ///
833    /// Returns an error if the database cannot be written.
834    pub fn install_collection(
835        &self,
836        collection_id: Uuid,
837        manifest_hash: &iroh_blobs::Hash,
838        version: &str,
839        path: &Path,
840    ) -> Result<()> {
841        let connection = self
842            .connection
843            .lock()
844            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
845        let now = current_timestamp().cast_signed();
846        connection
847            .execute(
848                "INSERT INTO installed_collections(collection_id, manifest_hash, current_version, installed_at)
849             VALUES (?1, ?2, ?3, ?4)
850             ON CONFLICT(collection_id) DO UPDATE SET
851                manifest_hash = excluded.manifest_hash,
852                current_version = excluded.current_version,
853                installed_at = excluded.installed_at",
854                params![collection_id.to_string(), manifest_hash.to_string(), version, now],
855            )
856            .map_err(|error| SyncwebError::operation("failed to install collection", error))?;
857        connection
858            .execute(
859                "INSERT OR IGNORE INTO collection_versions(collection_id, version, install_path) VALUES (?1, ?2, ?3)",
860                params![collection_id.to_string(), version, path.to_string_lossy().as_ref()],
861            )
862            .map_err(|error| SyncwebError::operation("failed to record collection version", error))?;
863        drop(connection);
864        Ok(())
865    }
866
867    /// Switch the active version of a collection.
868    ///
869    /// # Errors
870    ///
871    /// Returns an error if the database cannot be written.
872    pub fn switch_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
873        let connection = self
874            .connection
875            .lock()
876            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
877        connection
878            .execute(
879                "UPDATE installed_collections SET current_version = ?1 WHERE collection_id = ?2",
880                params![version, collection_id.to_string()],
881            )
882            .map_err(|error| SyncwebError::operation("failed to switch collection version", error))?;
883        drop(connection);
884        Ok(())
885    }
886
887    /// Remove a collection version from the database.
888    ///
889    /// # Errors
890    ///
891    /// Returns an error if the database cannot be written.
892    pub fn remove_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
893        let connection = self
894            .connection
895            .lock()
896            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
897        connection
898            .execute(
899                "DELETE FROM collection_versions WHERE collection_id = ?1 AND version = ?2",
900                params![collection_id.to_string(), version],
901            )
902            .map_err(|error| SyncwebError::operation("failed to remove collection version", error))?;
903
904        let remaining: i64 = connection
905            .query_row(
906                "SELECT COUNT(*) FROM collection_versions WHERE collection_id = ?1",
907                params![collection_id.to_string()],
908                |row| row.get(0),
909            )
910            .map_err(|error| SyncwebError::operation("failed to check remaining versions", error))?;
911        if remaining == 0 {
912            connection
913                .execute(
914                    "DELETE FROM installed_collections WHERE collection_id = ?1",
915                    params![collection_id.to_string()],
916                )
917                .map_err(|error| SyncwebError::operation("failed to remove installed collection", error))?;
918        }
919        drop(connection);
920        Ok(())
921    }
922
923    /// Get a configuration value.
924    ///
925    /// # Errors
926    ///
927    /// Returns an error if the database cannot be read.
928    pub fn get_config(&self, key: &str) -> Result<Option<String>> {
929        let connection = self
930            .connection
931            .lock()
932            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
933        let result = connection
934            .query_row("SELECT value FROM app_config WHERE key = ?1", params![key], |row| {
935                row.get::<_, String>(0)
936            })
937            .optional()
938            .map_err(|error| SyncwebError::operation("failed to get config", error))?;
939        drop(connection);
940        Ok(result)
941    }
942
943    /// Set a configuration value.
944    ///
945    /// # Errors
946    ///
947    /// Returns an error if the database cannot be written.
948    pub fn set_config(&self, key: &str, value: &str) -> Result<()> {
949        let connection = self
950            .connection
951            .lock()
952            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
953        let now = current_timestamp().cast_signed();
954        connection
955            .execute(
956                "INSERT INTO app_config(key, value, updated_at) VALUES (?1, ?2, ?3)
957             ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
958                params![key, value, now],
959            )
960            .map_err(|error| SyncwebError::operation("failed to set config", error))?;
961        drop(connection);
962        Ok(())
963    }
964
965    /// Load the application configuration.
966    ///
967    /// # Errors
968    ///
969    /// Returns an error if the database cannot be read or the config cannot be parsed.
970    pub fn load_app_config(&self) -> Result<AppConfig> {
971        let connection = self
972            .connection
973            .lock()
974            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
975        let blob: Option<String> = connection
976            .query_row("SELECT value FROM app_config WHERE key = 'config_blob'", [], |row| {
977                row.get(0)
978            })
979            .optional()
980            .map_err(|error| SyncwebError::operation("failed to load app config", error))?;
981        drop(connection);
982        blob.map_or_else(
983            || Ok(AppConfig::default()),
984            |toml_str| {
985                toml::from_str(&toml_str).map_err(|error| SyncwebError::operation("failed to parse app config", error))
986            },
987        )
988    }
989
990    /// Save the application configuration.
991    ///
992    /// # Errors
993    ///
994    /// Returns an error if the config cannot be serialized or the database cannot be written.
995    pub fn save_app_config(&self, config: &AppConfig) -> Result<()> {
996        let now = current_timestamp().cast_signed();
997        let blob = toml::to_string_pretty(config)
998            .map_err(|error| SyncwebError::operation("failed to serialize app config", error))?;
999        let connection = self
1000            .connection
1001            .lock()
1002            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1003        connection
1004            .execute(
1005                "INSERT INTO app_config(key, value, updated_at) VALUES ('config_blob', ?1, ?2)
1006             ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
1007                params![blob, now],
1008            )
1009            .map_err(|error| SyncwebError::operation("failed to save app config", error))?;
1010        drop(connection);
1011        Ok(())
1012    }
1013
1014    /// Load the filter engine state.
1015    ///
1016    /// # Errors
1017    ///
1018    /// Returns an error if the database cannot be read or the filter engine cannot be created.
1019    pub fn load_filter_engine(&self) -> Result<Option<FilterEngine>> {
1020        let connection = self
1021            .connection
1022            .lock()
1023            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1024        let mut stmt = connection
1025            .prepare("SELECT namespace_id, rule_type, pattern FROM filter_rules ORDER BY priority, id")
1026            .map_err(|error| SyncwebError::operation("failed to prepare filter query", error))?;
1027        let rows: Vec<(Option<String>, String, String)> = stmt
1028            .query_map([], |row| {
1029                Ok((
1030                    row.get::<_, Option<String>>(0)?,
1031                    row.get::<_, String>(1)?,
1032                    row.get::<_, String>(2)?,
1033                ))
1034            })
1035            .map_err(|error| SyncwebError::operation("failed to query filters", error))?
1036            .collect::<std::result::Result<Vec<_>, _>>()
1037            .map_err(|error| SyncwebError::operation("failed to read filter rows", error))?;
1038        drop(stmt);
1039        drop(connection);
1040
1041        if rows.is_empty() {
1042            return Ok(None);
1043        }
1044
1045        let mut global_rules = Vec::new();
1046        let mut folder_rules: HashMap<String, Vec<FilterRule>> = HashMap::new();
1047        for (namespace_id, rule_type, pattern) in rows {
1048            let action = match rule_type.as_str() {
1049                "include" => crate::filter::FilterAction::Accept,
1050                "exclude" => crate::filter::FilterAction::Reject,
1051                _ => continue,
1052            };
1053            let criteria = crate::filter::MatchCriteria {
1054                name: Some(pattern),
1055                ..Default::default()
1056            };
1057            let rule = FilterRule::new(action, criteria);
1058            match namespace_id {
1059                Some(ns) => folder_rules.entry(ns).or_default().push(rule),
1060                None => global_rules.push(rule),
1061            }
1062        }
1063
1064        let filter_config = crate::filter::FilterConfig {
1065            rules: global_rules,
1066            folders: folder_rules,
1067            default_action: FilterAction::default(),
1068        };
1069        FilterEngine::new(filter_config).map(Some)
1070    }
1071
1072    /// Save filter rules to the database.
1073    ///
1074    /// # Errors
1075    ///
1076    /// Returns an error if the database cannot be written.
1077    pub fn save_filter_rules(&self, rules: &[FilterEntry]) -> Result<()> {
1078        let connection = self
1079            .connection
1080            .lock()
1081            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1082        let now = current_timestamp().cast_signed();
1083        connection
1084            .execute("DELETE FROM filter_rules", [])
1085            .map_err(|error| SyncwebError::operation("failed to clear filter rules", error))?;
1086        for entry in rules {
1087            let rule_type = if entry.size > 0 { "exclude" } else { "include" };
1088            connection
1089                .execute(
1090                    "INSERT INTO filter_rules(namespace_id, rule_type, pattern, priority, updated_at)
1091                 VALUES (NULL, ?1, ?2, 0, ?3)",
1092                    params![rule_type, entry.path.to_string_lossy().as_ref(), now],
1093                )
1094                .map_err(|error| SyncwebError::operation("failed to save filter rule", error))?;
1095        }
1096        drop(connection);
1097        Ok(())
1098    }
1099
1100    /// Load all public subscription hashes and their sizes.
1101    ///
1102    /// # Errors
1103    ///
1104    /// Returns an error if the database cannot be read.
1105    pub fn load_subscriptions(&self) -> Result<HashSet<iroh_blobs::Hash>> {
1106        let connection = self
1107            .connection
1108            .lock()
1109            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1110        let mut stmt = connection
1111            .prepare("SELECT hash FROM public_subscriptions")
1112            .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1113        let hashes: HashSet<iroh_blobs::Hash> = stmt
1114            .query_map([], |row| {
1115                let text: String = row.get(0)?;
1116                Ok(text)
1117            })
1118            .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1119            .collect::<std::result::Result<Vec<_>, _>>()
1120            .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1121            .into_iter()
1122            .filter_map(|h| h.parse::<iroh_blobs::Hash>().ok())
1123            .collect();
1124        drop(stmt);
1125        drop(connection);
1126        Ok(hashes)
1127    }
1128
1129    /// Load subscription hashes with their cached sizes.
1130    ///
1131    /// # Errors
1132    ///
1133    /// Returns an error if the database cannot be read.
1134    pub fn load_subscriptions_with_sizes(&self) -> Result<Vec<(iroh_blobs::Hash, u64)>> {
1135        let connection = self
1136            .connection
1137            .lock()
1138            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1139        let mut stmt = connection
1140            .prepare("SELECT hash, size FROM public_subscriptions")
1141            .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1142        let results: Vec<(iroh_blobs::Hash, u64)> = stmt
1143            .query_map([], |row| {
1144                let text: String = row.get(0)?;
1145                let size: i64 = row.get(1)?;
1146                Ok((text, size))
1147            })
1148            .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1149            .collect::<std::result::Result<Vec<_>, _>>()
1150            .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1151            .into_iter()
1152            .filter_map(|(h, size)| {
1153                let hash = h.parse::<iroh_blobs::Hash>().ok()?;
1154                Some((hash, size.cast_unsigned()))
1155            })
1156            .collect();
1157        drop(stmt);
1158        drop(connection);
1159        Ok(results)
1160    }
1161
1162    /// Persist a public blob subscription.
1163    ///
1164    /// # Errors
1165    ///
1166    /// Returns an error if the database cannot be written.
1167    pub fn save_subscription(&self, hash: &iroh_blobs::Hash, size: u64) -> Result<()> {
1168        let connection = self
1169            .connection
1170            .lock()
1171            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1172        let now = current_timestamp().cast_signed();
1173        connection
1174            .execute(
1175                "INSERT OR IGNORE INTO public_subscriptions(hash, size, subscribed_at) VALUES (?1, ?2, ?3)",
1176                params![hash.to_string(), size.cast_signed(), now],
1177            )
1178            .map_err(|error| SyncwebError::operation("failed to save subscription", error))?;
1179        drop(connection);
1180        Ok(())
1181    }
1182
1183    /// Remove a public blob subscription.
1184    ///
1185    /// # Errors
1186    ///
1187    /// Returns an error if the database cannot be written.
1188    pub fn remove_subscription(&self, hash: &iroh_blobs::Hash) -> Result<()> {
1189        let connection = self
1190            .connection
1191            .lock()
1192            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1193        connection
1194            .execute(
1195                "DELETE FROM public_subscriptions WHERE hash = ?1",
1196                params![hash.to_string()],
1197            )
1198            .map_err(|error| SyncwebError::operation("failed to remove subscription", error))?;
1199        drop(connection);
1200        Ok(())
1201    }
1202
1203    // ------------------------------------------------------------------
1204    // Workspace manifest methods (replaces .syncweb-collection.json)
1205    // ------------------------------------------------------------------
1206
1207    /// Save a workspace manifest (serialized bytes) keyed by source path.
1208    ///
1209    /// # Errors
1210    ///
1211    /// Returns an error if the database write fails.
1212    pub fn save_workspace_manifest(&self, source_path: &str, manifest_bytes: &[u8], collection_id: &str) -> Result<()> {
1213        let connection = self
1214            .connection
1215            .lock()
1216            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1217        let now = current_timestamp().cast_signed();
1218        connection
1219            .execute(
1220                "INSERT OR REPLACE INTO workspace_manifests(source_path, manifest_bytes, collection_id, updated_at)
1221             VALUES (?1, ?2, ?3, ?4)",
1222                params![source_path, manifest_bytes, collection_id, now],
1223            )
1224            .map_err(|error| SyncwebError::operation("failed to save workspace manifest", error))?;
1225        drop(connection);
1226        Ok(())
1227    }
1228
1229    /// Load a workspace manifest by source path.
1230    ///
1231    /// # Errors
1232    ///
1233    /// Returns an error if the database query fails.
1234    pub fn load_workspace_manifest(&self, source_path: &str) -> Result<Option<Vec<u8>>> {
1235        let connection = self
1236            .connection
1237            .lock()
1238            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1239        let result: Option<Vec<u8>> = connection
1240            .query_row(
1241                "SELECT manifest_bytes FROM workspace_manifests WHERE source_path = ?1",
1242                params![source_path],
1243                |row| row.get(0),
1244            )
1245            .optional()
1246            .map_err(|error| SyncwebError::operation("failed to load workspace manifest", error))?;
1247        drop(connection);
1248        Ok(result)
1249    }
1250
1251    /// Delete a workspace manifest.
1252    ///
1253    /// # Errors
1254    ///
1255    /// Returns an error if the database write fails.
1256    pub fn delete_workspace_manifest(&self, source_path: &str) -> Result<()> {
1257        let connection = self
1258            .connection
1259            .lock()
1260            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1261        connection
1262            .execute(
1263                "DELETE FROM workspace_manifests WHERE source_path = ?1",
1264                params![source_path],
1265            )
1266            .map_err(|error| SyncwebError::operation("failed to delete workspace manifest", error))?;
1267        drop(connection);
1268        Ok(())
1269    }
1270
1271    // ------------------------------------------------------------------
1272    // Blob-folder index methods (network access control)
1273    // ------------------------------------------------------------------
1274
1275    /// Record a blob→folder association.
1276    ///
1277    /// # Errors
1278    ///
1279    /// Returns an error if the database write fails.
1280    pub fn record_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> Result<()> {
1281        let connection = self
1282            .connection
1283            .lock()
1284            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1285        let now = current_timestamp().cast_signed();
1286        connection
1287            .execute(
1288                "INSERT OR IGNORE INTO blob_folders(content_hash, namespace_id, entry_key, added_at)
1289             VALUES (?1, ?2, ?3, ?4)",
1290                params![content_hash.as_slice(), namespace_id, entry_key, now,],
1291            )
1292            .map_err(|error| SyncwebError::operation("failed to record blob folder association", error))?;
1293        drop(connection);
1294        Ok(())
1295    }
1296
1297    /// Remove a blob→folder association.
1298    ///
1299    /// # Errors
1300    ///
1301    /// Returns an error if the database write fails.
1302    pub fn remove_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> Result<()> {
1303        let connection = self
1304            .connection
1305            .lock()
1306            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1307        connection
1308            .execute(
1309                "DELETE FROM blob_folders WHERE content_hash = ?1 AND namespace_id = ?2 AND entry_key = ?3",
1310                params![content_hash.as_slice(), namespace_id, entry_key],
1311            )
1312            .map_err(|error| SyncwebError::operation("failed to remove blob folder association", error))?;
1313        drop(connection);
1314        Ok(())
1315    }
1316
1317    /// Check if a blob hash is accessible in any network the local node belongs to.
1318    /// Check if a blob is accessible in any network the member belongs to.
1319    ///
1320    /// # Errors
1321    ///
1322    /// Returns an error if the database query fails.
1323    pub fn can_access_blob(&self, hash: &Hash, member: &str) -> Result<bool> {
1324        self.can_access_blob_in_network(hash.as_bytes(), member)
1325    }
1326
1327    /// Check if a blob (by raw hash bytes) is accessible in any network.
1328    ///
1329    /// # Errors
1330    ///
1331    /// Returns an error if the database query fails.
1332    pub fn can_access_blob_in_network(&self, content_hash: &[u8; 32], member: &str) -> Result<bool> {
1333        let connection = self
1334            .connection
1335            .lock()
1336            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1337        let exists: bool = connection
1338            .query_row(
1339                "SELECT 1 FROM blob_folders bf
1340             JOIN network_folders nf ON bf.namespace_id = nf.namespace_id
1341             JOIN network_members nm ON nf.network_id = nm.network_id
1342             WHERE bf.content_hash = ?1 AND nm.member = ?2
1343             LIMIT 1",
1344                params![content_hash.as_slice(), member],
1345                |_| Ok(()),
1346            )
1347            .optional()
1348            .map_err(|error| SyncwebError::operation("failed to check blob access", error))?
1349            .is_some();
1350        drop(connection);
1351        Ok(exists)
1352    }
1353
1354    /// Check if the local node can access a folder namespace through any network.
1355    ///
1356    /// # Errors
1357    ///
1358    /// Returns an error if the database query fails.
1359    pub fn can_access_folder(&self, namespace_id: &str, member: &str) -> Result<bool> {
1360        let connection = self
1361            .connection
1362            .lock()
1363            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1364        let exists: bool = connection
1365            .query_row(
1366                "SELECT 1 FROM network_folders nf
1367             JOIN network_members nm ON nf.network_id = nm.network_id
1368             WHERE nf.namespace_id = ?1 AND nm.member = ?2
1369             LIMIT 1",
1370                params![namespace_id, member],
1371                |_| Ok(()),
1372            )
1373            .optional()
1374            .map_err(|error| SyncwebError::operation("failed to check folder access", error))?
1375            .is_some();
1376        drop(connection);
1377        Ok(exists)
1378    }
1379
1380    /// List all namespace IDs associated with a network.
1381    ///
1382    /// # Errors
1383    ///
1384    /// Returns an error if the database query fails.
1385    pub fn folders_for_network(&self, network_id: &str) -> Result<Vec<String>> {
1386        let connection = self
1387            .connection
1388            .lock()
1389            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1390        let mut stmt = connection
1391            .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
1392            .map_err(|error| SyncwebError::operation("failed to prepare folders query", error))?;
1393        let folders: Vec<String> = stmt
1394            .query_map(params![network_id], |row| row.get(0))
1395            .map_err(|error| SyncwebError::operation("failed to query folders", error))?
1396            .collect::<std::result::Result<Vec<_>, _>>()
1397            .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?;
1398        drop(stmt);
1399        drop(connection);
1400        Ok(folders)
1401    }
1402
1403    /// List all network IDs that contain a given folder namespace.
1404    ///
1405    /// # Errors
1406    ///
1407    /// Returns an error if the database query fails.
1408    pub fn networks_for_folder(&self, namespace_id: &str) -> Result<Vec<String>> {
1409        let connection = self
1410            .connection
1411            .lock()
1412            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1413        let mut stmt = connection
1414            .prepare("SELECT network_id FROM network_folders WHERE namespace_id = ?1")
1415            .map_err(|error| SyncwebError::operation("failed to prepare networks query", error))?;
1416        let networks: Vec<String> = stmt
1417            .query_map(params![namespace_id], |row| row.get(0))
1418            .map_err(|error| SyncwebError::operation("failed to query networks", error))?
1419            .collect::<std::result::Result<Vec<_>, _>>()
1420            .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
1421        drop(stmt);
1422        drop(connection);
1423        Ok(networks)
1424    }
1425
1426    /// List all member `PublicKey` strings for a network.
1427    ///
1428    /// # Errors
1429    ///
1430    /// Returns an error if the database query fails.
1431    pub fn members_of_network(&self, network_id: &str) -> Result<Vec<String>> {
1432        let connection = self
1433            .connection
1434            .lock()
1435            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1436        let mut stmt = connection
1437            .prepare("SELECT member FROM network_members WHERE network_id = ?1")
1438            .map_err(|error| SyncwebError::operation("failed to prepare members query", error))?;
1439        let members: Vec<String> = stmt
1440            .query_map(params![network_id], |row| row.get(0))
1441            .map_err(|error| SyncwebError::operation("failed to query members", error))?
1442            .collect::<std::result::Result<Vec<_>, _>>()
1443            .map_err(|error| SyncwebError::operation("failed to read member rows", error))?;
1444        drop(stmt);
1445        drop(connection);
1446        Ok(members)
1447    }
1448
1449    /// Returns true if the given peer is a member of any network.
1450    /// For the per-network daemon model, a daemon only manages ONE network.
1451    ///
1452    /// # Errors
1453    ///
1454    /// Returns an error if the database query fails.
1455    pub fn is_peer_network_member(&self, peer_public_key: &str) -> Result<bool> {
1456        let connection = self
1457            .connection
1458            .lock()
1459            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1460        let exists: bool = connection
1461            .query_row(
1462                "SELECT 1 FROM network_members WHERE member = ?1 LIMIT 1",
1463                params![peer_public_key],
1464                |_| Ok(()),
1465            )
1466            .optional()
1467            .map_err(|error| SyncwebError::operation("failed to check peer membership", error))?
1468            .is_some();
1469        drop(connection);
1470        Ok(exists)
1471    }
1472
1473    // ------------------------------------------------------------------
1474    // Sync checkpoint methods
1475    // ------------------------------------------------------------------
1476
1477    /// Create a new sync checkpoint session.
1478    ///
1479    /// # Errors
1480    ///
1481    /// Returns an error if the database write fails.
1482    pub fn create_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
1483        let connection = self
1484            .connection
1485            .lock()
1486            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1487        let now = current_timestamp().cast_signed();
1488        connection
1489            .execute(
1490                "INSERT INTO sync_checkpoints(namespace_id, session_id, started_at, last_updated_at, status)
1491             VALUES (?1, ?2, ?3, ?4, 'running')",
1492                params![namespace_id, session_id, now, now],
1493            )
1494            .map_err(|error| SyncwebError::operation("failed to create sync checkpoint", error))?;
1495        drop(connection);
1496        Ok(())
1497    }
1498
1499    /// Upsert a sync entry progress record.
1500    ///
1501    /// # Errors
1502    ///
1503    /// Returns an error if the database write fails.
1504    pub fn upsert_sync_entry(&self, p: &SyncEntryParams<'_>) -> Result<()> {
1505        let connection = self
1506            .connection
1507            .lock()
1508            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1509        let now = current_timestamp().cast_signed();
1510        connection
1511            .execute(
1512                "INSERT INTO sync_entry_progress(namespace_id, session_id, entry_key, hash, size, status, retries, error_message, updated_at)
1513             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1514             ON CONFLICT(namespace_id, session_id, entry_key) DO UPDATE SET
1515                status = excluded.status,
1516                retries = excluded.retries,
1517                error_message = excluded.error_message,
1518                updated_at = excluded.updated_at",
1519                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],
1520            )
1521            .map_err(|error| SyncwebError::operation("failed to upsert sync entry", error))?;
1522        drop(connection);
1523        Ok(())
1524    }
1525
1526    /// List sync entries for a session filtered by status.
1527    ///
1528    /// # Errors
1529    ///
1530    /// Returns an error if the database query fails.
1531    pub fn list_sync_entries(
1532        &self,
1533        namespace_id: &str,
1534        session_id: &str,
1535        status: &str,
1536    ) -> Result<Vec<crate::sync::checkpoint::EntryProgress>> {
1537        let connection = self
1538            .connection
1539            .lock()
1540            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1541        let mut stmt = connection
1542            .prepare(
1543                "SELECT entry_key, hash, size, status, retries, error_message
1544             FROM sync_entry_progress
1545             WHERE namespace_id = ?1 AND session_id = ?2 AND status = ?3",
1546            )
1547            .map_err(|error| SyncwebError::operation("failed to prepare sync entry query", error))?;
1548        let entries = stmt
1549            .query_map(params![namespace_id, session_id, status], |row| {
1550                let entry_key: Vec<u8> = row.get(0)?;
1551                let hash_bytes: Vec<u8> = row.get(1)?;
1552                let hash_str = String::from_utf8_lossy(&hash_bytes);
1553                let hash = hash_str.parse::<iroh_blobs::Hash>().unwrap_or(iroh_blobs::Hash::EMPTY);
1554                let size: i64 = row.get(2)?;
1555                let status_str: String = row.get(3)?;
1556                let retries: i64 = row.get(4)?;
1557                let error_message: Option<String> = row.get(5)?;
1558                Ok(crate::sync::checkpoint::EntryProgress {
1559                    entry_key,
1560                    hash,
1561                    size: size.cast_unsigned(),
1562                    status: status_str,
1563                    retries: u32::try_from(retries).unwrap_or(0),
1564                    error_message,
1565                })
1566            })
1567            .map_err(|error| SyncwebError::operation("failed to query sync entries", error))?
1568            .collect::<std::result::Result<Vec<_>, _>>()
1569            .map_err(|error| SyncwebError::operation("failed to read sync entry rows", error))?;
1570        drop(stmt);
1571        drop(connection);
1572        Ok(entries)
1573    }
1574
1575    /// Get checkpoint progress for a session.
1576    ///
1577    /// # Errors
1578    ///
1579    /// Returns an error if the database query fails.
1580    pub fn get_checkpoint_progress(
1581        &self,
1582        namespace_id: &str,
1583        session_id: &str,
1584    ) -> Result<crate::sync::checkpoint::CheckpointProgress> {
1585        let connection = self
1586            .connection
1587            .lock()
1588            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1589        let (total, completed, failed, skipped, pending, bytes_transferred, bytes_total): (
1590            i64, i64, i64, i64, i64, i64, Option<i64>,
1591        ) = connection
1592            .query_row(
1593                "SELECT
1594                (SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2),
1595                COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
1596                COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'failed'), 0),
1597                COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'skipped'), 0),
1598                COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'pending'), 0),
1599                COALESCE((SELECT SUM(size) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
1600                (SELECT bytes_total FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2)",
1601                params![namespace_id, session_id],
1602                |row| Ok((
1603                    row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?
1604                )),
1605            )
1606            .map_err(|error| SyncwebError::operation("failed to query checkpoint progress", error))?;
1607        drop(connection);
1608        let percentage = if total > 0 {
1609            let sum = completed.checked_add(skipped).unwrap_or(0);
1610            let pct_int = sum.checked_mul(100).and_then(|n| n.checked_div(total)).unwrap_or(0);
1611            f64::from(i32::try_from(pct_int).unwrap_or(0))
1612        } else {
1613            0.0
1614        };
1615        Ok(crate::sync::checkpoint::CheckpointProgress {
1616            total: usize::try_from(total).unwrap_or(0),
1617            completed: usize::try_from(completed).unwrap_or(0),
1618            failed: usize::try_from(failed).unwrap_or(0),
1619            skipped: usize::try_from(skipped).unwrap_or(0),
1620            pending: usize::try_from(pending).unwrap_or(0),
1621            bytes_transferred: bytes_transferred.cast_unsigned(),
1622            bytes_total: bytes_total.map(i64::cast_unsigned),
1623            percentage,
1624        })
1625    }
1626
1627    /// Update checkpoint status.
1628    ///
1629    /// # Errors
1630    ///
1631    /// Returns an error if the database write fails.
1632    pub fn update_checkpoint_status(&self, namespace_id: &str, session_id: &str, status: &str) -> Result<()> {
1633        let connection = self
1634            .connection
1635            .lock()
1636            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1637        let now = current_timestamp().cast_signed();
1638        connection
1639            .execute(
1640                "UPDATE sync_checkpoints SET status = ?1, last_updated_at = ?2
1641             WHERE namespace_id = ?3 AND session_id = ?4",
1642                params![status, now, namespace_id, session_id],
1643            )
1644            .map_err(|error| SyncwebError::operation("failed to update checkpoint status", error))?;
1645        drop(connection);
1646        Ok(())
1647    }
1648
1649    /// Find the most recent unfinished checkpoint for a namespace.
1650    ///
1651    /// # Errors
1652    ///
1653    /// Returns an error if the database query fails.
1654    pub fn find_unfinished_checkpoint(&self, namespace_id: &str) -> Result<Option<String>> {
1655        let connection = self
1656            .connection
1657            .lock()
1658            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1659        let result: Option<String> = connection
1660            .query_row(
1661                "SELECT session_id FROM sync_checkpoints
1662             WHERE namespace_id = ?1 AND status IN ('running', 'pending')
1663             ORDER BY started_at DESC LIMIT 1",
1664                params![namespace_id],
1665                |row| row.get(0),
1666            )
1667            .optional()
1668            .map_err(|error| SyncwebError::operation("failed to find unfinished checkpoint", error))?;
1669        drop(connection);
1670        Ok(result)
1671    }
1672
1673    /// Delete a sync checkpoint and its entries.
1674    ///
1675    /// # Errors
1676    ///
1677    /// Returns an error if the database write fails.
1678    pub fn delete_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
1679        let connection = self
1680            .connection
1681            .lock()
1682            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1683        connection
1684            .execute(
1685                "DELETE FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2",
1686                params![namespace_id, session_id],
1687            )
1688            .map_err(|error| SyncwebError::operation("failed to delete sync entry progress", error))?;
1689        connection
1690            .execute(
1691                "DELETE FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2",
1692                params![namespace_id, session_id],
1693            )
1694            .map_err(|error| SyncwebError::operation("failed to delete sync checkpoint", error))?;
1695        drop(connection);
1696        Ok(())
1697    }
1698
1699    /// Clean up stale (dangling) checkpoints older than the given number of seconds.
1700    ///
1701    /// # Errors
1702    ///
1703    /// Returns an error if the database write fails.
1704    pub fn clean_stale_checkpoints(&self, max_age_secs: i64) -> Result<usize> {
1705        let connection = self
1706            .connection
1707            .lock()
1708            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1709        let cutoff = current_timestamp().cast_signed().saturating_sub(max_age_secs);
1710        let deleted = connection
1711            .execute(
1712                "DELETE FROM sync_checkpoints WHERE last_updated_at < ?1 AND status NOT IN ('completed')",
1713                params![cutoff],
1714            )
1715            .map_err(|error| SyncwebError::operation("failed to clean stale checkpoints", error))?;
1716        drop(connection);
1717        Ok(deleted)
1718    }
1719
1720    // ------------------------------------------------------------------
1721    // Database maintenance methods
1722    // ------------------------------------------------------------------
1723
1724    /// Run VACUUM to reclaim space.
1725    ///
1726    /// # Errors
1727    ///
1728    /// Returns an error if the database operation fails.
1729    pub fn vacuum(&self) -> Result<()> {
1730        let connection = self
1731            .connection
1732            .lock()
1733            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1734        connection
1735            .execute_batch("VACUUM")
1736            .map_err(|error| SyncwebError::operation("vacuum failed", error))?;
1737        drop(connection);
1738        Ok(())
1739    }
1740
1741    /// Return estimated database size in bytes.
1742    ///
1743    /// # Errors
1744    ///
1745    /// Returns an error if the file metadata cannot be read.
1746    pub fn size_on_disk(&self) -> Result<u64> {
1747        let connection = self
1748            .connection
1749            .lock()
1750            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1751        let db_path: String = connection
1752            .query_row("PRAGMA database_list", [], |row| row.get(2))
1753            .map_err(|error| SyncwebError::operation("failed to query database path", error))?;
1754        drop(connection);
1755        std::fs::metadata(&db_path)
1756            .map(|meta| meta.len())
1757            .map_err(|error| SyncwebError::operation("failed to get database file size", error))
1758    }
1759
1760    /// Return the freelist page count (indicates whether VACUUM would help).
1761    ///
1762    /// # Errors
1763    ///
1764    /// Returns an error if the database operation fails.
1765    pub fn freelist_count(&self) -> Result<i64> {
1766        let connection = self
1767            .connection
1768            .lock()
1769            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1770        let count: i64 = connection
1771            .pragma_query_value(None, "freelist_count", |row| row.get(0))
1772            .map_err(|error| SyncwebError::operation("failed to query freelist count", error))?;
1773        drop(connection);
1774        Ok(count)
1775    }
1776
1777    /// Run `integrity_check` PRAGMA. Returns list of errors (empty = healthy).
1778    ///
1779    /// # Errors
1780    ///
1781    /// Returns an error if the database operation fails.
1782    pub fn check_integrity(&self) -> Result<Vec<String>> {
1783        let connection = self
1784            .connection
1785            .lock()
1786            .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1787        let mut stmt = connection
1788            .prepare("PRAGMA integrity_check")
1789            .map_err(|error| SyncwebError::operation("failed to prepare integrity check", error))?;
1790        let errors: Vec<String> = stmt
1791            .query_map([], |row| row.get::<_, String>(0))
1792            .map_err(|error| SyncwebError::operation("failed to run integrity check", error))?
1793            .filter_map(std::result::Result::ok)
1794            .filter(|s| s != "ok")
1795            .collect();
1796        drop(stmt);
1797        drop(connection);
1798        Ok(errors)
1799    }
1800}
1801
1802impl crate::storage::Vacuumable for NodeDatabase {
1803    fn vacuum(&self) -> Result<()> {
1804        self.vacuum()
1805    }
1806
1807    fn freelist_count(&self) -> Result<i64> {
1808        self.freelist_count()
1809    }
1810}
1811
1812#[cfg(test)]
1813mod tests {
1814    use super::*;
1815    use std::sync::Arc;
1816
1817    fn test_db() -> NodeDatabase {
1818        let conn = Connection::open_in_memory().unwrap();
1819        let db = NodeDatabase {
1820            connection: Arc::new(Mutex::new(conn)),
1821        };
1822        db.init_schema().unwrap();
1823        db
1824    }
1825
1826    fn make_public_key(seed: u8) -> PublicKey {
1827        iroh::SecretKey::from_bytes(&[seed; 32]).public()
1828    }
1829
1830    #[test]
1831    fn test_is_peer_network_member_true() {
1832        let db = test_db();
1833        let member_key = make_public_key(1);
1834        let owner = make_public_key(2);
1835
1836        let mut network = Network::new("test", owner, crate::net::network::NetworkOptions::default());
1837        network.members.insert(member_key);
1838        db.create_network(&network).unwrap();
1839
1840        assert!(db.is_peer_network_member(&member_key.to_string()).unwrap());
1841    }
1842
1843    #[test]
1844    fn test_is_peer_network_member_false() {
1845        let db = test_db();
1846        assert!(!db.is_peer_network_member("unknown-peer").unwrap());
1847    }
1848}