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