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