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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}