1use std::{
2 collections::{BTreeMap, HashMap, HashSet},
3 path::{Path, PathBuf},
4 str::FromStr,
5 sync::{Arc, Mutex},
6};
7
8use iroh::PublicKey;
9use iroh_docs::NamespaceId;
10use rusqlite::{Connection, OptionalExtension, params};
11use serde::{Deserialize, Serialize};
12use tracing;
13use uuid::Uuid;
14
15use crate::{
16 Result, SyncwebError,
17 daemon::{
18 BandwidthSnapshot, DaemonState, DaemonStatus, DaemonStatusReport, FolderStatusReport, ScheduleStatus,
19 current_timestamp,
20 },
21 filter::{FilterAction, FilterEngine, FilterEntry, FilterRule},
22 folder::{CollectionState, InstalledCollection},
23 net::network::{Network, NetworkId, parse_public_key},
24 storage::config::Config as AppConfig,
25};
26
27#[derive(Debug)]
29#[non_exhaustive]
30pub struct SyncEntryParams<'a> {
31 pub namespace_id: &'a str,
32 pub session_id: &'a str,
33 pub entry_key: &'a [u8],
34 pub hash: &'a [u8],
35 pub size: u64,
36 pub status: &'a str,
37 pub retries: u32,
38 pub error_message: Option<&'a str>,
39}
40
41#[derive(Clone, Debug, Deserialize, Serialize)]
42#[non_exhaustive]
43pub struct StorageRootRecord {
44 pub id: String,
45 pub path: PathBuf,
46 pub min_free: u64,
47 pub enabled: bool,
48}
49
50impl StorageRootRecord {
51 #[must_use]
53 pub fn new(id: impl Into<String>, path: impl Into<PathBuf>, min_free: u64, enabled: bool) -> Self {
54 Self {
55 id: id.into(),
56 path: path.into(),
57 min_free,
58 enabled,
59 }
60 }
61}
62
63#[derive(Clone, Debug, Deserialize, Serialize)]
64#[non_exhaustive]
65pub struct TransferJobRecord {
66 pub id: String,
67 pub namespace_id: String,
68 pub entry_key: Vec<u8>,
69 pub hash: [u8; 32],
70 pub size: u64,
71 pub root_id: Option<String>,
72 pub destination: Option<PathBuf>,
73 pub state: String,
74 pub bytes_transferred: u64,
75 pub peer_count: u64,
76 pub eta_seconds: Option<u64>,
77 pub retries: u32,
78 pub error_message: Option<String>,
79 pub created_at: u64,
80 pub updated_at: u64,
81}
82
83#[derive(Debug)]
84#[non_exhaustive]
85pub struct NewTransferJob<'a> {
86 pub namespace_id: &'a str,
87 pub entry_key: &'a [u8],
88 pub hash: &'a [u8; 32],
89 pub size: u64,
90 pub root_id: Option<&'a str>,
91 pub destination: Option<&'a Path>,
92}
93
94impl<'a> NewTransferJob<'a> {
95 #[must_use]
97 pub const fn new(
98 namespace_id: &'a str,
99 entry_key: &'a [u8],
100 hash: &'a [u8; 32],
101 size: u64,
102 root_id: Option<&'a str>,
103 destination: Option<&'a Path>,
104 ) -> Self {
105 Self {
106 namespace_id,
107 entry_key,
108 hash,
109 size,
110 root_id,
111 destination,
112 }
113 }
114}
115
116fn transfer_job_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TransferJobRecord> {
117 let hash_bytes: Vec<u8> = row.get(3)?;
118 let hash = hash_bytes.try_into().map_err(|bytes: Vec<u8>| {
119 rusqlite::Error::FromSqlConversionFailure(
120 3,
121 rusqlite::types::Type::Blob,
122 format!("expected 32-byte hash, got {} bytes", bytes.len()).into(),
123 )
124 })?;
125 let size: i64 = row.get(4)?;
126 let bytes_transferred: i64 = row.get(8)?;
127 let peer_count: i64 = row.get(9)?;
128 let eta_seconds: Option<i64> = row.get(10)?;
129 let retries: i64 = row.get(11)?;
130 let created_at: i64 = row.get(13)?;
131 let updated_at: i64 = row.get(14)?;
132 let retry_count = u32::try_from(retries).map_err(|error| {
133 rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Integer, Box::new(error))
134 })?;
135 Ok(TransferJobRecord {
136 id: row.get(0)?,
137 namespace_id: row.get(1)?,
138 entry_key: row.get(2)?,
139 hash,
140 size: size.cast_unsigned(),
141 root_id: row.get(5)?,
142 destination: row.get::<_, Option<String>>(6)?.map(PathBuf::from),
143 state: row.get(7)?,
144 bytes_transferred: bytes_transferred.cast_unsigned(),
145 peer_count: peer_count.cast_unsigned(),
146 eta_seconds: eta_seconds.map(i64::cast_unsigned),
147 retries: retry_count,
148 error_message: row.get(12)?,
149 created_at: created_at.cast_unsigned(),
150 updated_at: updated_at.cast_unsigned(),
151 })
152}
153
154#[derive(Clone, Debug)]
155pub struct NodeDatabase {
156 connection: Arc<Mutex<Connection>>,
157}
158
159impl NodeDatabase {
160 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
166 let db_path = path.as_ref();
167 if let Some(parent) = db_path.parent() {
168 std::fs::create_dir_all(parent)
169 .map_err(|error| SyncwebError::operation("failed to create node db directory", error))?;
170 }
171 let connection = Connection::open(db_path)
172 .map_err(|error| SyncwebError::operation("failed to open node database", error))?;
173 let db = Self {
174 connection: Arc::new(Mutex::new(connection)),
175 };
176 db.init_schema()?;
177 Ok(db)
178 }
179
180 const CREATE_SCHEMA_SQL: &'static str =
181 "CREATE TABLE IF NOT EXISTS schema_version (
182 key TEXT PRIMARY KEY,
183 value TEXT NOT NULL
184 );
185 CREATE TABLE IF NOT EXISTS daemon_lifecycle (
186 id INTEGER PRIMARY KEY CHECK(id = 1),
187 pid INTEGER NOT NULL,
188 node_id TEXT NOT NULL,
189 started_at INTEGER NOT NULL,
190 status TEXT NOT NULL CHECK(status IN ('starting','running','stopping','stopped')),
191 data_dir TEXT NOT NULL,
192 updated_at INTEGER NOT NULL
193 );
194 CREATE TABLE IF NOT EXISTS daemon_status (
195 id INTEGER PRIMARY KEY CHECK(id = 1),
196 pid INTEGER NOT NULL,
197 node_id TEXT NOT NULL,
198 started_at INTEGER NOT NULL,
199 uptime_seconds INTEGER NOT NULL,
200 upload_total INTEGER NOT NULL DEFAULT 0,
201 download_total INTEGER NOT NULL DEFAULT 0,
202 upload_rate INTEGER NOT NULL DEFAULT 0,
203 download_rate INTEGER NOT NULL DEFAULT 0,
204 has_schedule INTEGER NOT NULL DEFAULT 0,
205 in_active_window INTEGER NOT NULL DEFAULT 0,
206 next_window_start INTEGER,
207 rayon_threads INTEGER NOT NULL,
208 updated_at INTEGER NOT NULL
209 );
210 CREATE TABLE IF NOT EXISTS folder_status_reports (
211 namespace_id TEXT NOT NULL,
212 path TEXT NOT NULL,
213 session_active INTEGER NOT NULL DEFAULT 0,
214 last_sync_at INTEGER,
215 entries_synced INTEGER NOT NULL DEFAULT 0,
216 errors_json TEXT NOT NULL DEFAULT '[]',
217 updated_at INTEGER NOT NULL,
218 PRIMARY KEY(namespace_id)
219 );
220 CREATE TABLE IF NOT EXISTS folder_mounts (
221 namespace_id TEXT PRIMARY KEY,
222 path TEXT NOT NULL,
223 updated_at INTEGER NOT NULL
224 );
225 CREATE TABLE IF NOT EXISTS networks (
226 id TEXT PRIMARY KEY,
227 name TEXT NOT NULL UNIQUE,
228 label TEXT NOT NULL DEFAULT '',
229 owner TEXT NOT NULL,
230 shared_secret BLOB,
231 created_at INTEGER NOT NULL
232 );
233 CREATE TABLE IF NOT EXISTS network_members (
234 network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
235 member TEXT NOT NULL,
236 PRIMARY KEY(network_id, member)
237 );
238 CREATE TABLE IF NOT EXISTS network_folders (
239 network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
240 namespace_id TEXT NOT NULL,
241 PRIMARY KEY(network_id, namespace_id)
242 );
243 CREATE TABLE IF NOT EXISTS installed_collections (
244 collection_id TEXT PRIMARY KEY,
245 manifest_hash TEXT NOT NULL,
246 current_version TEXT NOT NULL,
247 installed_at INTEGER NOT NULL
248 );
249 CREATE TABLE IF NOT EXISTS collection_versions (
250 collection_id TEXT NOT NULL REFERENCES installed_collections(collection_id) ON DELETE CASCADE,
251 version TEXT NOT NULL,
252 install_path TEXT NOT NULL,
253 PRIMARY KEY(collection_id, version)
254 );
255 CREATE TABLE IF NOT EXISTS app_config (
256 key TEXT PRIMARY KEY,
257 value TEXT NOT NULL,
258 updated_at INTEGER NOT NULL
259 );
260 CREATE TABLE IF NOT EXISTS filter_rules (
261 id INTEGER PRIMARY KEY AUTOINCREMENT,
262 namespace_id TEXT,
263 rule_type TEXT NOT NULL,
264 pattern TEXT NOT NULL,
265 priority INTEGER NOT NULL DEFAULT 0,
266 updated_at INTEGER NOT NULL
267 );
268 CREATE TABLE IF NOT EXISTS public_subscriptions (
269 hash TEXT PRIMARY KEY,
270 size INTEGER NOT NULL,
271 subscribed_at INTEGER NOT NULL
272 );
273 CREATE TABLE IF NOT EXISTS shares (
274 namespace_id TEXT NOT NULL,
275 access TEXT NOT NULL CHECK(access IN ('read','write')),
276 ticket TEXT NOT NULL,
277 created_at INTEGER NOT NULL,
278 PRIMARY KEY(namespace_id, access)
279 );
280 CREATE TABLE IF NOT EXISTS workspace_manifests (
281 source_path TEXT NOT NULL,
282 manifest_bytes BLOB NOT NULL,
283 collection_id TEXT NOT NULL,
284 updated_at INTEGER NOT NULL,
285 PRIMARY KEY(source_path)
286 );
287 CREATE TABLE IF NOT EXISTS blob_folders (
288 content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
289 namespace_id TEXT NOT NULL,
290 entry_key BLOB NOT NULL,
291 added_at INTEGER NOT NULL,
292 PRIMARY KEY(content_hash, namespace_id, entry_key)
293 );
294 CREATE INDEX IF NOT EXISTS idx_blob_folders_hash ON blob_folders(content_hash);
295 CREATE TABLE IF NOT EXISTS sync_checkpoints (
296 namespace_id TEXT NOT NULL,
297 session_id TEXT NOT NULL,
298 total_entries INTEGER NOT NULL DEFAULT 0,
299 processed_entries INTEGER NOT NULL DEFAULT 0,
300 failed_entries INTEGER NOT NULL DEFAULT 0,
301 bytes_total INTEGER,
302 bytes_transferred INTEGER NOT NULL DEFAULT 0,
303 started_at INTEGER NOT NULL,
304 last_updated_at INTEGER NOT NULL,
305 status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','completed','failed','cancelled')),
306 PRIMARY KEY(namespace_id, session_id)
307 );
308 CREATE TABLE IF NOT EXISTS sync_entry_progress (
309 namespace_id TEXT NOT NULL,
310 session_id TEXT NOT NULL,
311 entry_key BLOB NOT NULL,
312 hash BLOB NOT NULL,
313 size INTEGER NOT NULL,
314 status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','downloading','completed','failed','skipped')),
315 retries INTEGER NOT NULL DEFAULT 0,
316 error_message TEXT,
317 updated_at INTEGER NOT NULL,
318 PRIMARY KEY(namespace_id, session_id, entry_key),
319 FOREIGN KEY(namespace_id, session_id) REFERENCES sync_checkpoints(namespace_id, session_id) ON DELETE CASCADE
320 );
321 CREATE TABLE IF NOT EXISTS storage_roots (
322 id TEXT PRIMARY KEY,
323 path TEXT NOT NULL UNIQUE,
324 min_free INTEGER NOT NULL DEFAULT 0 CHECK(min_free >= 0),
325 enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
326 created_at INTEGER NOT NULL,
327 updated_at INTEGER NOT NULL
328 );
329 CREATE TABLE IF NOT EXISTS transfer_jobs (
330 id TEXT PRIMARY KEY,
331 namespace_id TEXT NOT NULL,
332 entry_key BLOB NOT NULL,
333 hash BLOB NOT NULL CHECK(length(hash) = 32),
334 size INTEGER NOT NULL CHECK(size >= 0),
335 root_id TEXT,
336 destination TEXT,
337 state TEXT NOT NULL DEFAULT 'queued'
338 CHECK(state IN ('queued','fetching','materializing','paused','completed','failed','cancelled')),
339 bytes_transferred INTEGER NOT NULL DEFAULT 0 CHECK(bytes_transferred >= 0),
340 peer_count INTEGER NOT NULL DEFAULT 0 CHECK(peer_count >= 0),
341 eta_seconds INTEGER,
342 retries INTEGER NOT NULL DEFAULT 0 CHECK(retries >= 0),
343 error_message TEXT,
344 created_at INTEGER NOT NULL,
345 updated_at INTEGER NOT NULL,
346 UNIQUE(namespace_id, entry_key, root_id),
347 FOREIGN KEY(root_id) REFERENCES storage_roots(id) ON DELETE SET NULL
348 );";
349
350 const fn create_schema_sql() -> &'static str {
352 Self::CREATE_SCHEMA_SQL
353 }
354
355 pub fn init_schema(&self) -> Result<()> {
361 let connection = self
362 .connection
363 .lock()
364 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
365 connection
366 .busy_timeout(std::time::Duration::from_secs(5))
367 .map_err(|error| SyncwebError::operation("failed to configure node database", error))?;
368 connection
369 .pragma_update(None, "journal_mode", "WAL")
370 .map_err(|error| SyncwebError::operation("failed to set WAL mode", error))?;
371 connection
372 .pragma_update(None, "foreign_keys", "ON")
373 .map_err(|error| SyncwebError::operation("failed to enable foreign keys", error))?;
374 connection
375 .execute_batch(Self::create_schema_sql())
376 .map_err(|error| SyncwebError::operation("failed to initialize node database schema", error))?;
377 if let Err(error) = connection.execute_batch("ALTER TABLE networks ADD COLUMN doc_ticket TEXT") {
378 tracing::debug!(%error, "migration: add doc_ticket (may already exist)");
379 }
380 if let Err(error) = connection.execute_batch(
381 "CREATE INDEX IF NOT EXISTS idx_transfer_jobs_namespace ON transfer_jobs(namespace_id);
382 CREATE INDEX IF NOT EXISTS idx_transfer_jobs_state ON transfer_jobs(state);
383 CREATE INDEX IF NOT EXISTS idx_transfer_jobs_root ON transfer_jobs(root_id);",
384 ) {
385 tracing::debug!(%error, "migration: add transfer job indexes (may already exist)");
386 }
387
388 connection
389 .execute(
390 "INSERT INTO schema_version(key, value) VALUES ('version', '1')
391 ON CONFLICT(key) DO NOTHING",
392 [],
393 )
394 .map_err(|error| SyncwebError::operation("failed to seed schema version", error))?;
395 drop(connection);
396 Ok(())
397 }
398
399 pub fn save_lifecycle(&self, state: &DaemonState) -> Result<()> {
405 let connection = self
406 .connection
407 .lock()
408 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
409 let status = match state.status {
410 DaemonStatus::Starting => "starting",
411 DaemonStatus::Running => "running",
412 DaemonStatus::Stopping => "stopping",
413 DaemonStatus::Stopped => "stopped",
414 };
415 let now = current_timestamp();
416 connection
417 .execute(
418 "INSERT INTO daemon_lifecycle(id, pid, node_id, started_at, status, data_dir, updated_at)
419 VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)
420 ON CONFLICT(id) DO UPDATE SET
421 pid = excluded.pid,
422 node_id = excluded.node_id,
423 started_at = excluded.started_at,
424 status = excluded.status,
425 data_dir = excluded.data_dir,
426 updated_at = excluded.updated_at",
427 params![
428 i64::from(state.pid),
429 state.node_id,
430 state.started_at.cast_signed(),
431 status,
432 state.data_dir.to_string_lossy().as_ref(),
433 now.cast_signed(),
434 ],
435 )
436 .map_err(|error| SyncwebError::operation("failed to save daemon lifecycle", error))?;
437 drop(connection);
438 Ok(())
439 }
440
441 pub fn load_lifecycle(&self) -> Result<Option<DaemonState>> {
447 let connection = self
448 .connection
449 .lock()
450 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
451 let result = connection
452 .query_row(
453 "SELECT pid, node_id, started_at, status, data_dir FROM daemon_lifecycle WHERE id = 1",
454 [],
455 |row| {
456 let pid: i64 = row.get(0)?;
457 let node_id: String = row.get(1)?;
458 let started_at: i64 = row.get(2)?;
459 let status_str: String = row.get(3)?;
460 let data_dir: String = row.get(4)?;
461 let status = match status_str.as_str() {
462 "starting" => DaemonStatus::Starting,
463 "running" => DaemonStatus::Running,
464 "stopping" => DaemonStatus::Stopping,
465 "stopped" => DaemonStatus::Stopped,
466 _ => {
467 return Err(rusqlite::Error::InvalidColumnType(
468 3,
469 "status".to_string(),
470 rusqlite::types::Type::Text,
471 ));
472 }
473 };
474 Ok(DaemonState::new(
475 u32::try_from(pid).unwrap_or_default(),
476 node_id,
477 started_at.cast_unsigned(),
478 data_dir,
479 status,
480 ))
481 },
482 )
483 .optional()
484 .map_err(|error| SyncwebError::operation("failed to load daemon lifecycle", error))?;
485 drop(connection);
486 Ok(result)
487 }
488
489 pub fn remove_lifecycle(&self) -> Result<()> {
495 let connection = self
496 .connection
497 .lock()
498 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
499 connection
500 .execute("DELETE FROM daemon_lifecycle WHERE id = 1", [])
501 .map_err(|error| SyncwebError::operation("failed to remove daemon lifecycle", error))?;
502 drop(connection);
503 Ok(())
504 }
505
506 pub fn save_status(&self, report: &DaemonStatusReport) -> Result<()> {
512 let connection = self
513 .connection
514 .lock()
515 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
516 let now = current_timestamp().cast_signed();
517 let has_schedule = report.schedule.is_some();
518 connection
519 .execute(
520 "INSERT INTO daemon_status(id, pid, node_id, started_at, uptime_seconds,
521 upload_total, download_total, upload_rate, download_rate,
522 has_schedule, in_active_window, next_window_start, rayon_threads, updated_at)
523 VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
524 ON CONFLICT(id) DO UPDATE SET
525 pid = excluded.pid,
526 node_id = excluded.node_id,
527 started_at = excluded.started_at,
528 uptime_seconds = excluded.uptime_seconds,
529 upload_total = excluded.upload_total,
530 download_total = excluded.download_total,
531 upload_rate = excluded.upload_rate,
532 download_rate = excluded.download_rate,
533 has_schedule = excluded.has_schedule,
534 in_active_window = excluded.in_active_window,
535 next_window_start = excluded.next_window_start,
536 rayon_threads = excluded.rayon_threads,
537 updated_at = excluded.updated_at",
538 params![
539 i64::from(report.pid),
540 report.node_id,
541 report.started_at.cast_signed(),
542 report.uptime_seconds.cast_signed(),
543 report.bandwidth.upload_total.cast_signed(),
544 report.bandwidth.download_total.cast_signed(),
545 report.bandwidth.upload_rate.cast_signed(),
546 report.bandwidth.download_rate.cast_signed(),
547 i64::from(has_schedule),
548 report.schedule.map_or(0, |s| i64::from(s.in_active_window)),
549 report.schedule.and_then(|s| s.next_window_start.map(u64::cast_signed)),
550 i64::try_from(report.rayon_threads).unwrap_or_default(),
551 now,
552 ],
553 )
554 .map_err(|error| SyncwebError::operation("failed to save daemon status", error))?;
555
556 for folder in &report.folders {
557 let errors_json = serde_json::to_string(&folder.errors).unwrap_or_else(|_| "[]".to_string());
558 connection
559 .execute(
560 "INSERT INTO folder_status_reports(namespace_id, path, session_active, last_sync_at,
561 entries_synced, errors_json, updated_at)
562 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
563 ON CONFLICT(namespace_id) DO UPDATE SET
564 path = excluded.path,
565 session_active = excluded.session_active,
566 last_sync_at = excluded.last_sync_at,
567 entries_synced = excluded.entries_synced,
568 errors_json = excluded.errors_json,
569 updated_at = excluded.updated_at",
570 params![
571 folder.namespace,
572 folder.path.to_string_lossy().as_ref(),
573 i64::from(folder.session_active),
574 folder.last_sync_at.map(u64::cast_signed),
575 folder.entries_synced.cast_signed(),
576 errors_json,
577 now,
578 ],
579 )
580 .map_err(|error| SyncwebError::operation("failed to save folder status", error))?;
581 }
582 drop(connection);
583 Ok(())
584 }
585
586 pub fn load_status(&self) -> Result<Option<DaemonStatusReport>> {
592 let connection = self
593 .connection
594 .lock()
595 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
596 let result: Option<DaemonStatusReport> = connection
597 .query_row(
598 "SELECT pid, node_id, started_at, uptime_seconds,
599 upload_total, download_total, upload_rate, download_rate,
600 has_schedule, in_active_window, next_window_start, rayon_threads
601 FROM daemon_status WHERE id = 1",
602 [],
603 |row| {
604 let pid: i64 = row.get(0)?;
605 let node_id: String = row.get(1)?;
606 let started_at: i64 = row.get(2)?;
607 let uptime_seconds: i64 = row.get(3)?;
608 let upload_total: i64 = row.get(4)?;
609 let download_total: i64 = row.get(5)?;
610 let upload_rate: i64 = row.get(6)?;
611 let download_rate: i64 = row.get(7)?;
612 let has_schedule: i64 = row.get(8)?;
613 let in_active_window: i64 = row.get(9)?;
614 let next_window_start: Option<i64> = row.get(10)?;
615 let rayon_threads: i64 = row.get(11)?;
616
617 let schedule = (has_schedule != 0).then(|| ScheduleStatus {
618 in_active_window: in_active_window != 0,
619 next_window_start: next_window_start.map(i64::cast_unsigned),
620 });
621
622 Ok(DaemonStatusReport {
623 pid: u32::try_from(pid).unwrap_or_default(),
624 node_id,
625 started_at: started_at.cast_unsigned(),
626 uptime_seconds: uptime_seconds.cast_unsigned(),
627 folders: Vec::new(),
628 bandwidth: BandwidthSnapshot {
629 upload_total: upload_total.cast_unsigned(),
630 download_total: download_total.cast_unsigned(),
631 upload_rate: upload_rate.cast_unsigned(),
632 download_rate: download_rate.cast_unsigned(),
633 },
634 schedule,
635 rayon_threads: usize::try_from(rayon_threads).unwrap_or_default(),
636 })
637 },
638 )
639 .optional()
640 .map_err(|error| SyncwebError::operation("failed to load daemon status", error))?;
641
642 if let Some(mut report) = result {
643 let mut folder_stmt = connection
644 .prepare(
645 "SELECT namespace_id, path, session_active, last_sync_at, entries_synced, errors_json
646 FROM folder_status_reports",
647 )
648 .map_err(|error| SyncwebError::operation("failed to prepare folder status query", error))?;
649 let folders: Vec<FolderStatusReport> = folder_stmt
650 .query_map([], |row| {
651 let namespace: String = row.get(0)?;
652 let path: String = row.get(1)?;
653 let session_active: i64 = row.get(2)?;
654 let last_sync_at: Option<i64> = row.get(3)?;
655 let entries_synced: i64 = row.get(4)?;
656 let errors_json: String = row.get(5)?;
657 let errors: Vec<String> = serde_json::from_str(&errors_json).unwrap_or_default();
658 Ok(FolderStatusReport::new(
659 namespace,
660 path,
661 session_active != 0,
662 last_sync_at.map(i64::cast_unsigned),
663 entries_synced.cast_unsigned(),
664 errors,
665 ))
666 })
667 .map_err(|error| SyncwebError::operation("failed to query folder statuses", error))?
668 .collect::<std::result::Result<Vec<_>, _>>()
669 .map_err(|error| SyncwebError::operation("failed to read folder status rows", error))?;
670 report.folders = folders;
671 drop(folder_stmt);
672 drop(connection);
673 Ok(Some(report))
674 } else {
675 drop(connection);
676 Ok(None)
677 }
678 }
679
680 pub fn remove_status(&self) -> Result<()> {
686 let connection = self
687 .connection
688 .lock()
689 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
690 connection
691 .execute("DELETE FROM daemon_status WHERE id = 1", [])
692 .map_err(|error| SyncwebError::operation("failed to remove daemon status", error))?;
693 connection
694 .execute("DELETE FROM folder_status_reports", [])
695 .map_err(|error| SyncwebError::operation("failed to remove folder status reports", error))?;
696 drop(connection);
697 Ok(())
698 }
699
700 pub fn upsert_folder_mount(&self, namespace_id: &str, path: &Path) -> Result<()> {
710 let connection = self
711 .connection
712 .lock()
713 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
714 let now = current_timestamp().cast_signed();
715 connection
716 .execute(
717 "INSERT INTO folder_mounts(namespace_id, path, updated_at) VALUES (?1, ?2, ?3)
718 ON CONFLICT(namespace_id) DO UPDATE SET
719 path = excluded.path,
720 updated_at = excluded.updated_at",
721 params![namespace_id, path.to_string_lossy().as_ref(), now],
722 )
723 .map_err(|error| SyncwebError::operation("failed to save folder mount", error))?;
724 drop(connection);
725 Ok(())
726 }
727
728 pub fn load_folder_mounts(&self) -> Result<Vec<(String, PathBuf)>> {
734 let connection = self
735 .connection
736 .lock()
737 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
738 let mut stmt = connection
739 .prepare("SELECT namespace_id, path FROM folder_mounts")
740 .map_err(|error| SyncwebError::operation("failed to prepare folder mount query", error))?;
741 let rows = stmt
742 .query_map([], |row| {
743 Ok((row.get::<_, String>(0)?, PathBuf::from(row.get::<_, String>(1)?)))
744 })
745 .map_err(|error| SyncwebError::operation("failed to query folder mounts", error))?
746 .collect::<std::result::Result<Vec<_>, _>>()
747 .map_err(|error| SyncwebError::operation("failed to read folder mount rows", error))?;
748 drop(stmt);
749 drop(connection);
750 Ok(rows)
751 }
752
753 pub fn remove_folder_mount(&self, namespace_id: &str) -> Result<()> {
759 let connection = self
760 .connection
761 .lock()
762 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
763 connection
764 .execute(
765 "DELETE FROM folder_mounts WHERE namespace_id = ?1",
766 params![namespace_id],
767 )
768 .map_err(|error| SyncwebError::operation("failed to remove folder mount", error))?;
769 drop(connection);
770 Ok(())
771 }
772
773 pub fn create_network(&self, network: &Network) -> Result<()> {
779 let connection = self
780 .connection
781 .lock()
782 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
783 let now = current_timestamp().cast_signed();
784 connection.execute(
785 "INSERT INTO networks(id, name, label, owner, shared_secret, created_at, doc_ticket) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
786 params![
787 network.id.to_string(),
788 network.name,
789 network.label,
790 network.owner.to_string(),
791 network.shared_secret.as_ref().map(|s| s.to_vec()),
792 now,
793 network.doc_ticket,
794 ],
795 ).map_err(|error| SyncwebError::operation("failed to create network", error))?;
796 for member in &network.members {
797 connection
798 .execute(
799 "INSERT INTO network_members(network_id, member) VALUES (?1, ?2)",
800 params![network.id.to_string(), member.to_string()],
801 )
802 .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
803 }
804 for folder in &network.folders {
805 connection
806 .execute(
807 "INSERT INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
808 params![network.id.to_string(), folder.to_string()],
809 )
810 .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
811 }
812 drop(connection);
813 Ok(())
814 }
815
816 pub fn delete_network(&self, id: NetworkId) -> Result<()> {
822 let connection = self
823 .connection
824 .lock()
825 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
826 let affected = connection
827 .execute("DELETE FROM networks WHERE id = ?1", params![id.to_string()])
828 .map_err(|error| SyncwebError::operation("failed to delete network", error))?;
829 if affected == 0 {
830 drop(connection);
831 return Err(SyncwebError::FolderNotFound(format!("network {id}")));
832 }
833 drop(connection);
834 Ok(())
835 }
836
837 pub fn set_network_doc_ticket(&self, id: NetworkId, doc_ticket: &Option<String>) -> Result<()> {
843 let connection = self
844 .connection
845 .lock()
846 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
847 connection
848 .execute(
849 "UPDATE networks SET doc_ticket = ?1 WHERE id = ?2",
850 params![doc_ticket, id.to_string()],
851 )
852 .map_err(|error| SyncwebError::operation("failed to update network doc ticket", error))?;
853 drop(connection);
854 Ok(())
855 }
856
857 pub fn add_member(&self, network_id: NetworkId, member: PublicKey) -> Result<()> {
863 let connection = self
864 .connection
865 .lock()
866 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
867 connection
868 .execute(
869 "INSERT OR IGNORE INTO network_members(network_id, member) VALUES (?1, ?2)",
870 params![network_id.to_string(), member.to_string()],
871 )
872 .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
873 drop(connection);
874 Ok(())
875 }
876
877 pub fn remove_member(&self, network_id: NetworkId, member: &PublicKey) -> Result<bool> {
883 let connection = self
884 .connection
885 .lock()
886 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
887 let affected = connection
888 .execute(
889 "DELETE FROM network_members WHERE network_id = ?1 AND member = ?2",
890 params![network_id.to_string(), member.to_string()],
891 )
892 .map_err(|error| SyncwebError::operation("failed to remove network member", error))?;
893 drop(connection);
894 Ok(affected > 0)
895 }
896
897 pub fn add_folder_to_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
903 let connection = self
904 .connection
905 .lock()
906 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
907 connection
908 .execute(
909 "INSERT OR IGNORE INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
910 params![network_id.to_string(), namespace_id.to_string()],
911 )
912 .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
913 drop(connection);
914 Ok(())
915 }
916
917 pub fn remove_folder_from_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
923 let connection = self
924 .connection
925 .lock()
926 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
927 connection
928 .execute(
929 "DELETE FROM network_folders WHERE network_id = ?1 AND namespace_id = ?2",
930 params![network_id.to_string(), namespace_id.to_string()],
931 )
932 .map_err(|error| SyncwebError::operation("failed to remove network folder", error))?;
933 drop(connection);
934 Ok(())
935 }
936
937 pub fn list_networks(&self) -> Result<Vec<Network>> {
943 let connection = self
944 .connection
945 .lock()
946 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
947 let mut stmt = connection
948 .prepare("SELECT id, name, label, owner, shared_secret, doc_ticket FROM networks ORDER BY name")
949 .map_err(|error| SyncwebError::operation("failed to prepare network query", error))?;
950 let networks = stmt
951 .query_map([], |row| {
952 Ok((
953 row.get::<_, String>(0)?,
954 row.get::<_, String>(1)?,
955 row.get::<_, String>(2)?,
956 row.get::<_, String>(3)?,
957 row.get::<_, Option<Vec<u8>>>(4)?,
958 row.get::<_, Option<String>>(5)?,
959 ))
960 })
961 .map_err(|error| SyncwebError::operation("failed to query networks", error))?
962 .collect::<std::result::Result<Vec<_>, _>>()
963 .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
964
965 let mut member_stmt = connection
966 .prepare("SELECT member FROM network_members WHERE network_id = ?1")
967 .map_err(|error| SyncwebError::operation("failed to prepare member query", error))?;
968 let mut folder_stmt = connection
969 .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
970 .map_err(|error| SyncwebError::operation("failed to prepare folder query", error))?;
971
972 let mut result = Vec::new();
973 for (id_str, name, label, owner_str, shared_secret_bytes, doc_ticket) in networks {
974 let network_id = NetworkId::from_str(&id_str)
975 .map_err(|error| SyncwebError::operation("invalid network ID in database", error))?;
976 let owner = parse_public_key(&owner_str)?;
977 let shared_secret = shared_secret_bytes
978 .map(|bytes| -> Result<[u8; 32]> {
979 bytes.try_into().map_err(|_bytes: Vec<u8>| {
980 SyncwebError::operation("invalid shared secret length in database", "expected 32 bytes")
981 })
982 })
983 .transpose()?;
984
985 let members: HashSet<PublicKey> = member_stmt
986 .query_map(params![id_str], |row| row.get::<_, String>(0))
987 .map_err(|error| SyncwebError::operation("failed to query members", error))?
988 .collect::<std::result::Result<Vec<_>, _>>()
989 .map_err(|error| SyncwebError::operation("failed to read member rows", error))?
990 .into_iter()
991 .map(|s| parse_public_key(&s))
992 .collect::<Result<HashSet<_>>>()?;
993
994 let folders: HashSet<NamespaceId> = folder_stmt
995 .query_map(params![id_str], |row| row.get::<_, String>(0))
996 .map_err(|error| SyncwebError::operation("failed to query folders", error))?
997 .collect::<std::result::Result<Vec<_>, _>>()
998 .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?
999 .into_iter()
1000 .map(|s| s.parse())
1001 .collect::<std::result::Result<HashSet<_>, _>>()
1002 .map_err(|error| SyncwebError::operation("invalid folder namespace in database", error))?;
1003
1004 result.push(Network {
1005 id: network_id,
1006 name,
1007 label,
1008 owner,
1009 members,
1010 folders,
1011 shared_secret,
1012 doc_ticket,
1013 });
1014 }
1015 drop(stmt);
1016 drop(member_stmt);
1017 drop(folder_stmt);
1018 drop(connection);
1019 Ok(result)
1020 }
1021
1022 pub fn get_collection_state(&self) -> Result<CollectionState> {
1028 let connection = self
1029 .connection
1030 .lock()
1031 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1032 let mut stmt = connection
1033 .prepare("SELECT collection_id, manifest_hash, current_version FROM installed_collections")
1034 .map_err(|error| SyncwebError::operation("failed to prepare collection query", error))?;
1035 let collections: Vec<(String, String, String)> = stmt
1036 .query_map([], |row| {
1037 Ok((
1038 row.get::<_, String>(0)?,
1039 row.get::<_, String>(1)?,
1040 row.get::<_, String>(2)?,
1041 ))
1042 })
1043 .map_err(|error| SyncwebError::operation("failed to query collections", error))?
1044 .collect::<std::result::Result<Vec<_>, _>>()
1045 .map_err(|error| SyncwebError::operation("failed to read collection rows", error))?;
1046
1047 let mut version_stmt = connection
1048 .prepare("SELECT version, install_path FROM collection_versions WHERE collection_id = ?1")
1049 .map_err(|error| SyncwebError::operation("failed to prepare version query", error))?;
1050
1051 let mut installed = BTreeMap::new();
1052 for (id_str, manifest_hash, current_version) in collections {
1053 let collection_id = Uuid::from_str(&id_str)
1054 .map_err(|error| SyncwebError::operation("invalid collection ID in database", error))?;
1055 let manifest = iroh_blobs::Hash::from_str(&manifest_hash)
1056 .map_err(|error| SyncwebError::operation("invalid manifest hash in database", error))?;
1057
1058 let versions: BTreeMap<String, PathBuf> = version_stmt
1059 .query_map(params![id_str], |row| {
1060 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1061 })
1062 .map_err(|error| SyncwebError::operation("failed to query versions", error))?
1063 .collect::<std::result::Result<Vec<_>, _>>()
1064 .map_err(|error| SyncwebError::operation("failed to read version rows", error))?
1065 .into_iter()
1066 .map(|(v, p)| (v, PathBuf::from(p)))
1067 .collect();
1068
1069 installed.insert(
1070 collection_id,
1071 InstalledCollection {
1072 manifest,
1073 versions,
1074 current: current_version,
1075 },
1076 );
1077 }
1078 drop(stmt);
1079 drop(version_stmt);
1080 drop(connection);
1081 Ok(CollectionState { installed })
1082 }
1083
1084 pub fn install_collection(
1090 &self,
1091 collection_id: Uuid,
1092 manifest_hash: &iroh_blobs::Hash,
1093 version: &str,
1094 path: &Path,
1095 ) -> Result<()> {
1096 let connection = self
1097 .connection
1098 .lock()
1099 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1100 let now = current_timestamp().cast_signed();
1101 connection
1102 .execute(
1103 "INSERT INTO installed_collections(collection_id, manifest_hash, current_version, installed_at)
1104 VALUES (?1, ?2, ?3, ?4)
1105 ON CONFLICT(collection_id) DO UPDATE SET
1106 manifest_hash = excluded.manifest_hash,
1107 current_version = excluded.current_version,
1108 installed_at = excluded.installed_at",
1109 params![collection_id.to_string(), manifest_hash.to_string(), version, now],
1110 )
1111 .map_err(|error| SyncwebError::operation("failed to install collection", error))?;
1112 connection
1113 .execute(
1114 "INSERT OR IGNORE INTO collection_versions(collection_id, version, install_path) VALUES (?1, ?2, ?3)",
1115 params![collection_id.to_string(), version, path.to_string_lossy().as_ref()],
1116 )
1117 .map_err(|error| SyncwebError::operation("failed to record collection version", error))?;
1118 drop(connection);
1119 Ok(())
1120 }
1121
1122 pub fn switch_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
1128 let connection = self
1129 .connection
1130 .lock()
1131 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1132 connection
1133 .execute(
1134 "UPDATE installed_collections SET current_version = ?1 WHERE collection_id = ?2",
1135 params![version, collection_id.to_string()],
1136 )
1137 .map_err(|error| SyncwebError::operation("failed to switch collection version", error))?;
1138 drop(connection);
1139 Ok(())
1140 }
1141
1142 pub fn remove_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
1148 let connection = self
1149 .connection
1150 .lock()
1151 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1152 connection
1153 .execute(
1154 "DELETE FROM collection_versions WHERE collection_id = ?1 AND version = ?2",
1155 params![collection_id.to_string(), version],
1156 )
1157 .map_err(|error| SyncwebError::operation("failed to remove collection version", error))?;
1158
1159 let remaining: i64 = connection
1160 .query_row(
1161 "SELECT COUNT(*) FROM collection_versions WHERE collection_id = ?1",
1162 params![collection_id.to_string()],
1163 |row| row.get(0),
1164 )
1165 .map_err(|error| SyncwebError::operation("failed to check remaining versions", error))?;
1166 if remaining == 0 {
1167 connection
1168 .execute(
1169 "DELETE FROM installed_collections WHERE collection_id = ?1",
1170 params![collection_id.to_string()],
1171 )
1172 .map_err(|error| SyncwebError::operation("failed to remove installed collection", error))?;
1173 }
1174 drop(connection);
1175 Ok(())
1176 }
1177
1178 pub fn get_config(&self, key: &str) -> Result<Option<String>> {
1184 let connection = self
1185 .connection
1186 .lock()
1187 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1188 let result = connection
1189 .query_row("SELECT value FROM app_config WHERE key = ?1", params![key], |row| {
1190 row.get::<_, String>(0)
1191 })
1192 .optional()
1193 .map_err(|error| SyncwebError::operation("failed to get config", error))?;
1194 drop(connection);
1195 Ok(result)
1196 }
1197
1198 pub fn set_config(&self, key: &str, value: &str) -> Result<()> {
1204 let connection = self
1205 .connection
1206 .lock()
1207 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1208 let now = current_timestamp().cast_signed();
1209 connection
1210 .execute(
1211 "INSERT INTO app_config(key, value, updated_at) VALUES (?1, ?2, ?3)
1212 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
1213 params![key, value, now],
1214 )
1215 .map_err(|error| SyncwebError::operation("failed to set config", error))?;
1216 drop(connection);
1217 Ok(())
1218 }
1219
1220 pub fn load_app_config(&self) -> Result<AppConfig> {
1226 let connection = self
1227 .connection
1228 .lock()
1229 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1230 let blob: Option<String> = connection
1231 .query_row("SELECT value FROM app_config WHERE key = 'config_blob'", [], |row| {
1232 row.get(0)
1233 })
1234 .optional()
1235 .map_err(|error| SyncwebError::operation("failed to load app config", error))?;
1236 drop(connection);
1237 blob.map_or_else(
1238 || Ok(AppConfig::default()),
1239 |toml_str| {
1240 toml::from_str(&toml_str).map_err(|error| SyncwebError::operation("failed to parse app config", error))
1241 },
1242 )
1243 }
1244
1245 pub fn save_app_config(&self, config: &AppConfig) -> Result<()> {
1251 let now = current_timestamp().cast_signed();
1252 let blob = toml::to_string_pretty(config)
1253 .map_err(|error| SyncwebError::operation("failed to serialize app config", error))?;
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 "INSERT INTO app_config(key, value, updated_at) VALUES ('config_blob', ?1, ?2)
1261 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
1262 params![blob, now],
1263 )
1264 .map_err(|error| SyncwebError::operation("failed to save app config", error))?;
1265 drop(connection);
1266 Ok(())
1267 }
1268
1269 pub fn load_filter_engine(&self) -> Result<Option<FilterEngine>> {
1275 let connection = self
1276 .connection
1277 .lock()
1278 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1279 let mut stmt = connection
1280 .prepare("SELECT namespace_id, rule_type, pattern FROM filter_rules ORDER BY priority, id")
1281 .map_err(|error| SyncwebError::operation("failed to prepare filter query", error))?;
1282 let rows: Vec<(Option<String>, String, String)> = stmt
1283 .query_map([], |row| {
1284 Ok((
1285 row.get::<_, Option<String>>(0)?,
1286 row.get::<_, String>(1)?,
1287 row.get::<_, String>(2)?,
1288 ))
1289 })
1290 .map_err(|error| SyncwebError::operation("failed to query filters", error))?
1291 .collect::<std::result::Result<Vec<_>, _>>()
1292 .map_err(|error| SyncwebError::operation("failed to read filter rows", error))?;
1293 drop(stmt);
1294 drop(connection);
1295
1296 if rows.is_empty() {
1297 return Ok(None);
1298 }
1299
1300 let mut global_rules = Vec::new();
1301 let mut folder_rules: HashMap<String, Vec<FilterRule>> = HashMap::new();
1302 for (namespace_id, rule_type, pattern) in rows {
1303 let action = match rule_type.as_str() {
1304 "include" => crate::filter::FilterAction::Accept,
1305 "exclude" => crate::filter::FilterAction::Reject,
1306 _ => continue,
1307 };
1308 let criteria = crate::filter::MatchCriteria {
1309 name: Some(pattern),
1310 ..Default::default()
1311 };
1312 let rule = FilterRule::new(action, criteria);
1313 match namespace_id {
1314 Some(ns) => folder_rules.entry(ns).or_default().push(rule),
1315 None => global_rules.push(rule),
1316 }
1317 }
1318
1319 let filter_config = crate::filter::FilterConfig {
1320 rules: global_rules,
1321 folders: folder_rules,
1322 default_action: FilterAction::default(),
1323 };
1324 FilterEngine::new(filter_config).map(Some)
1325 }
1326
1327 pub fn save_filter_rules(&self, rules: &[FilterEntry]) -> Result<()> {
1333 let connection = self
1334 .connection
1335 .lock()
1336 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1337 let now = current_timestamp().cast_signed();
1338 connection
1339 .execute("DELETE FROM filter_rules", [])
1340 .map_err(|error| SyncwebError::operation("failed to clear filter rules", error))?;
1341 for entry in rules {
1342 let rule_type = if entry.size > 0 { "exclude" } else { "include" };
1343 connection
1344 .execute(
1345 "INSERT INTO filter_rules(namespace_id, rule_type, pattern, priority, updated_at)
1346 VALUES (NULL, ?1, ?2, 0, ?3)",
1347 params![rule_type, entry.path.to_string_lossy().as_ref(), now],
1348 )
1349 .map_err(|error| SyncwebError::operation("failed to save filter rule", error))?;
1350 }
1351 drop(connection);
1352 Ok(())
1353 }
1354
1355 pub fn load_subscriptions(&self) -> Result<HashSet<iroh_blobs::Hash>> {
1361 let connection = self
1362 .connection
1363 .lock()
1364 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1365 let mut stmt = connection
1366 .prepare("SELECT hash FROM public_subscriptions")
1367 .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1368 let hashes: HashSet<iroh_blobs::Hash> = stmt
1369 .query_map([], |row| {
1370 let text: String = row.get(0)?;
1371 Ok(text)
1372 })
1373 .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1374 .collect::<std::result::Result<Vec<_>, _>>()
1375 .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1376 .into_iter()
1377 .filter_map(|h| h.parse::<iroh_blobs::Hash>().ok())
1378 .collect();
1379 drop(stmt);
1380 drop(connection);
1381 Ok(hashes)
1382 }
1383
1384 pub fn load_subscriptions_with_sizes(&self) -> Result<Vec<(iroh_blobs::Hash, u64)>> {
1390 let connection = self
1391 .connection
1392 .lock()
1393 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1394 let mut stmt = connection
1395 .prepare("SELECT hash, size FROM public_subscriptions")
1396 .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1397 let results: Vec<(iroh_blobs::Hash, u64)> = stmt
1398 .query_map([], |row| {
1399 let text: String = row.get(0)?;
1400 let size: i64 = row.get(1)?;
1401 Ok((text, size))
1402 })
1403 .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1404 .collect::<std::result::Result<Vec<_>, _>>()
1405 .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1406 .into_iter()
1407 .filter_map(|(h, size)| {
1408 let hash = h.parse::<iroh_blobs::Hash>().ok()?;
1409 Some((hash, size.cast_unsigned()))
1410 })
1411 .collect();
1412 drop(stmt);
1413 drop(connection);
1414 Ok(results)
1415 }
1416
1417 pub fn save_subscription(&self, hash: &iroh_blobs::Hash, size: u64) -> Result<()> {
1423 let connection = self
1424 .connection
1425 .lock()
1426 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1427 let now = current_timestamp().cast_signed();
1428 connection
1429 .execute(
1430 "INSERT OR IGNORE INTO public_subscriptions(hash, size, subscribed_at) VALUES (?1, ?2, ?3)",
1431 params![hash.to_string(), size.cast_signed(), now],
1432 )
1433 .map_err(|error| SyncwebError::operation("failed to save subscription", error))?;
1434 drop(connection);
1435 Ok(())
1436 }
1437
1438 pub fn remove_subscription(&self, hash: &iroh_blobs::Hash) -> Result<()> {
1444 let connection = self
1445 .connection
1446 .lock()
1447 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1448 connection
1449 .execute(
1450 "DELETE FROM public_subscriptions WHERE hash = ?1",
1451 params![hash.to_string()],
1452 )
1453 .map_err(|error| SyncwebError::operation("failed to remove subscription", error))?;
1454 drop(connection);
1455 Ok(())
1456 }
1457
1458 pub fn add_share(&self, namespace_id: &str, access: &str, ticket: &str) -> Result<()> {
1468 let connection = self
1469 .connection
1470 .lock()
1471 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1472 connection
1473 .execute(
1474 "INSERT INTO shares (namespace_id, access, ticket, created_at)
1475 VALUES (?1, ?2, ?3, ?4)
1476 ON CONFLICT(namespace_id, access)
1477 DO UPDATE SET ticket = excluded.ticket",
1478 params![namespace_id, access, ticket, current_timestamp().cast_signed()],
1479 )
1480 .map_err(|error| SyncwebError::operation("failed to save share record", error))?;
1481 drop(connection);
1482 Ok(())
1483 }
1484
1485 pub fn remove_share(&self, namespace_id: &str, access: &str) -> Result<()> {
1491 let connection = self
1492 .connection
1493 .lock()
1494 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1495 connection
1496 .execute(
1497 "DELETE FROM shares WHERE namespace_id = ?1 AND access = ?2",
1498 params![namespace_id, access],
1499 )
1500 .map_err(|error| SyncwebError::operation("failed to remove share record", error))?;
1501 drop(connection);
1502 Ok(())
1503 }
1504
1505 pub fn list_shares(&self) -> Result<Vec<(String, String, String)>> {
1511 let connection = self
1512 .connection
1513 .lock()
1514 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1515 let mut stmt = connection
1516 .prepare("SELECT namespace_id, access, ticket FROM shares ORDER BY created_at")
1517 .map_err(|error| SyncwebError::operation("failed to prepare share query", error))?;
1518 let rows = stmt
1519 .query_map([], |row| {
1520 Ok((
1521 row.get::<_, String>(0)?,
1522 row.get::<_, String>(1)?,
1523 row.get::<_, String>(2)?,
1524 ))
1525 })
1526 .map_err(|error| SyncwebError::operation("failed to query shares", error))?
1527 .collect::<std::result::Result<Vec<_>, _>>()
1528 .map_err(|error| SyncwebError::operation("failed to read share rows", error))?;
1529 drop(stmt);
1530 drop(connection);
1531 Ok(rows)
1532 }
1533
1534 pub fn save_workspace_manifest(&self, source_path: &str, manifest_bytes: &[u8], collection_id: &str) -> Result<()> {
1544 let connection = self
1545 .connection
1546 .lock()
1547 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1548 let now = current_timestamp().cast_signed();
1549 connection
1550 .execute(
1551 "INSERT OR REPLACE INTO workspace_manifests(source_path, manifest_bytes, collection_id, updated_at)
1552 VALUES (?1, ?2, ?3, ?4)",
1553 params![source_path, manifest_bytes, collection_id, now],
1554 )
1555 .map_err(|error| SyncwebError::operation("failed to save workspace manifest", error))?;
1556 drop(connection);
1557 Ok(())
1558 }
1559
1560 pub fn load_workspace_manifest(&self, source_path: &str) -> Result<Option<Vec<u8>>> {
1566 let connection = self
1567 .connection
1568 .lock()
1569 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1570 let result: Option<Vec<u8>> = connection
1571 .query_row(
1572 "SELECT manifest_bytes FROM workspace_manifests WHERE source_path = ?1",
1573 params![source_path],
1574 |row| row.get(0),
1575 )
1576 .optional()
1577 .map_err(|error| SyncwebError::operation("failed to load workspace manifest", error))?;
1578 drop(connection);
1579 Ok(result)
1580 }
1581
1582 pub fn delete_workspace_manifest(&self, source_path: &str) -> Result<()> {
1588 let connection = self
1589 .connection
1590 .lock()
1591 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1592 connection
1593 .execute(
1594 "DELETE FROM workspace_manifests WHERE source_path = ?1",
1595 params![source_path],
1596 )
1597 .map_err(|error| SyncwebError::operation("failed to delete workspace manifest", error))?;
1598 drop(connection);
1599 Ok(())
1600 }
1601
1602 pub fn record_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> Result<()> {
1612 let connection = self
1613 .connection
1614 .lock()
1615 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1616 let now = current_timestamp().cast_signed();
1617 connection
1618 .execute(
1619 "INSERT OR IGNORE INTO blob_folders(content_hash, namespace_id, entry_key, added_at)
1620 VALUES (?1, ?2, ?3, ?4)",
1621 params![content_hash.as_slice(), namespace_id, entry_key, now,],
1622 )
1623 .map_err(|error| SyncwebError::operation("failed to record blob folder association", error))?;
1624 drop(connection);
1625 Ok(())
1626 }
1627
1628 pub fn remove_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> Result<()> {
1634 let connection = self
1635 .connection
1636 .lock()
1637 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1638 connection
1639 .execute(
1640 "DELETE FROM blob_folders WHERE content_hash = ?1 AND namespace_id = ?2 AND entry_key = ?3",
1641 params![content_hash.as_slice(), namespace_id, entry_key],
1642 )
1643 .map_err(|error| SyncwebError::operation("failed to remove blob folder association", error))?;
1644 drop(connection);
1645 Ok(())
1646 }
1647
1648 pub fn folders_for_network(&self, network_id: &str) -> Result<Vec<String>> {
1654 let connection = self
1655 .connection
1656 .lock()
1657 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1658 let mut stmt = connection
1659 .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
1660 .map_err(|error| SyncwebError::operation("failed to prepare folders query", error))?;
1661 let folders: Vec<String> = stmt
1662 .query_map(params![network_id], |row| row.get(0))
1663 .map_err(|error| SyncwebError::operation("failed to query folders", error))?
1664 .collect::<std::result::Result<Vec<_>, _>>()
1665 .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?;
1666 drop(stmt);
1667 drop(connection);
1668 Ok(folders)
1669 }
1670
1671 pub fn networks_for_folder(&self, namespace_id: &str) -> Result<Vec<String>> {
1677 let connection = self
1678 .connection
1679 .lock()
1680 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1681 let mut stmt = connection
1682 .prepare("SELECT network_id FROM network_folders WHERE namespace_id = ?1")
1683 .map_err(|error| SyncwebError::operation("failed to prepare networks query", error))?;
1684 let networks: Vec<String> = stmt
1685 .query_map(params![namespace_id], |row| row.get(0))
1686 .map_err(|error| SyncwebError::operation("failed to query networks", error))?
1687 .collect::<std::result::Result<Vec<_>, _>>()
1688 .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
1689 drop(stmt);
1690 drop(connection);
1691 Ok(networks)
1692 }
1693
1694 pub fn members_of_network(&self, network_id: &str) -> Result<Vec<String>> {
1700 let connection = self
1701 .connection
1702 .lock()
1703 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1704 let mut stmt = connection
1705 .prepare("SELECT member FROM network_members WHERE network_id = ?1")
1706 .map_err(|error| SyncwebError::operation("failed to prepare members query", error))?;
1707 let members: Vec<String> = stmt
1708 .query_map(params![network_id], |row| row.get(0))
1709 .map_err(|error| SyncwebError::operation("failed to query members", error))?
1710 .collect::<std::result::Result<Vec<_>, _>>()
1711 .map_err(|error| SyncwebError::operation("failed to read member rows", error))?;
1712 drop(stmt);
1713 drop(connection);
1714 Ok(members)
1715 }
1716
1717 pub fn is_peer_network_member(&self, peer_public_key: &str) -> Result<bool> {
1724 let connection = self
1725 .connection
1726 .lock()
1727 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1728 let exists: bool = connection
1729 .query_row(
1730 "SELECT 1 FROM network_members WHERE member = ?1 LIMIT 1",
1731 params![peer_public_key],
1732 |_| Ok(()),
1733 )
1734 .optional()
1735 .map_err(|error| SyncwebError::operation("failed to check peer membership", error))?
1736 .is_some();
1737 drop(connection);
1738 Ok(exists)
1739 }
1740
1741 pub fn upsert_storage_root(&self, root: &StorageRootRecord) -> Result<()> {
1751 let connection = self
1752 .connection
1753 .lock()
1754 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1755 let now = current_timestamp().cast_signed();
1756 connection
1757 .execute(
1758 "INSERT INTO storage_roots(id, path, min_free, enabled, created_at, updated_at)
1759 VALUES (?1, ?2, ?3, ?4, ?5, ?5)
1760 ON CONFLICT(id) DO UPDATE SET
1761 path = excluded.path,
1762 min_free = excluded.min_free,
1763 enabled = excluded.enabled,
1764 updated_at = excluded.updated_at",
1765 params![
1766 root.id,
1767 root.path.to_string_lossy().as_ref(),
1768 root.min_free.cast_signed(),
1769 i64::from(root.enabled),
1770 now,
1771 ],
1772 )
1773 .map_err(|error| SyncwebError::operation("failed to save storage root", error))?;
1774 drop(connection);
1775 Ok(())
1776 }
1777
1778 pub fn list_storage_roots(&self) -> Result<Vec<StorageRootRecord>> {
1784 let connection = self
1785 .connection
1786 .lock()
1787 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1788 let mut statement = connection
1789 .prepare("SELECT id, path, min_free, enabled FROM storage_roots ORDER BY id")
1790 .map_err(|error| SyncwebError::operation("failed to prepare storage root query", error))?;
1791 let roots = statement
1792 .query_map([], |row| {
1793 let min_free: i64 = row.get(2)?;
1794 let enabled: i64 = row.get(3)?;
1795 Ok(StorageRootRecord {
1796 id: row.get(0)?,
1797 path: PathBuf::from(row.get::<_, String>(1)?),
1798 min_free: min_free.cast_unsigned(),
1799 enabled: enabled != 0,
1800 })
1801 })
1802 .map_err(|error| SyncwebError::operation("failed to query storage roots", error))?
1803 .collect::<std::result::Result<Vec<_>, _>>()
1804 .map_err(|error| SyncwebError::operation("failed to read storage root rows", error))?;
1805 drop(statement);
1806 drop(connection);
1807 Ok(roots)
1808 }
1809
1810 pub fn remove_storage_root(&self, id: &str) -> Result<()> {
1816 let connection = self
1817 .connection
1818 .lock()
1819 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1820 connection
1821 .execute("DELETE FROM storage_roots WHERE id = ?1", params![id])
1822 .map_err(|error| SyncwebError::operation("failed to remove storage root", error))?;
1823 drop(connection);
1824 Ok(())
1825 }
1826
1827 pub fn enqueue_transfer_job(&self, job: &NewTransferJob<'_>) -> Result<String> {
1833 let id = Uuid::new_v4().to_string();
1834 let connection = self
1835 .connection
1836 .lock()
1837 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1838 let now = current_timestamp().cast_signed();
1839 connection
1840 .execute(
1841 "INSERT INTO transfer_jobs(
1842 id, namespace_id, entry_key, hash, size, root_id, destination,
1843 state, created_at, updated_at
1844 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'queued', ?8, ?8)",
1845 params![
1846 id,
1847 job.namespace_id,
1848 job.entry_key,
1849 job.hash.as_slice(),
1850 job.size.cast_signed(),
1851 job.root_id,
1852 job.destination.map(|path| path.to_string_lossy().into_owned()),
1853 now,
1854 ],
1855 )
1856 .map_err(|error| SyncwebError::operation("failed to enqueue transfer job", error))?;
1857 drop(connection);
1858 Ok(id)
1859 }
1860
1861 pub fn list_transfer_jobs(
1867 &self,
1868 namespace_id: Option<&str>,
1869 state: Option<&str>,
1870 ) -> Result<Vec<TransferJobRecord>> {
1871 let connection = self
1872 .connection
1873 .lock()
1874 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1875 let jobs = match (namespace_id, state) {
1876 (Some(namespace_filter), Some(state_filter)) => {
1877 let mut statement = connection
1878 .prepare(
1879 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1880 state, bytes_transferred, peer_count, eta_seconds, retries,
1881 error_message, created_at, updated_at
1882 FROM transfer_jobs
1883 WHERE namespace_id = ?1 AND state = ?2
1884 ORDER BY created_at, id",
1885 )
1886 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1887 statement
1888 .query_map(params![namespace_filter, state_filter], transfer_job_from_row)
1889 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1890 .collect::<std::result::Result<Vec<_>, _>>()
1891 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1892 }
1893 (Some(namespace_filter), None) => {
1894 let mut statement = connection
1895 .prepare(
1896 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1897 state, bytes_transferred, peer_count, eta_seconds, retries,
1898 error_message, created_at, updated_at
1899 FROM transfer_jobs
1900 WHERE namespace_id = ?1
1901 ORDER BY created_at, id",
1902 )
1903 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1904 statement
1905 .query_map(params![namespace_filter], transfer_job_from_row)
1906 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1907 .collect::<std::result::Result<Vec<_>, _>>()
1908 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1909 }
1910 (None, Some(state_filter)) => {
1911 let mut statement = connection
1912 .prepare(
1913 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1914 state, bytes_transferred, peer_count, eta_seconds, retries,
1915 error_message, created_at, updated_at
1916 FROM transfer_jobs
1917 WHERE state = ?1
1918 ORDER BY created_at, id",
1919 )
1920 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1921 statement
1922 .query_map(params![state_filter], transfer_job_from_row)
1923 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1924 .collect::<std::result::Result<Vec<_>, _>>()
1925 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1926 }
1927 (None, None) => {
1928 let mut statement = connection
1929 .prepare(
1930 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1931 state, bytes_transferred, peer_count, eta_seconds, retries,
1932 error_message, created_at, updated_at
1933 FROM transfer_jobs
1934 ORDER BY created_at, id",
1935 )
1936 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1937 statement
1938 .query_map([], transfer_job_from_row)
1939 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1940 .collect::<std::result::Result<Vec<_>, _>>()
1941 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1942 }
1943 };
1944 drop(connection);
1945 Ok(jobs)
1946 }
1947
1948 pub fn update_transfer_job_state(&self, id: &str, state: &str, error_message: Option<&str>) -> Result<()> {
1954 let connection = self
1955 .connection
1956 .lock()
1957 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1958 let now = current_timestamp().cast_signed();
1959 let updated = connection
1960 .execute(
1961 "UPDATE transfer_jobs
1962 SET state = ?1, error_message = ?2, updated_at = ?3
1963 WHERE id = ?4",
1964 params![state, error_message, now, id],
1965 )
1966 .map_err(|error| SyncwebError::operation("failed to update transfer job state", error))?;
1967 if updated == 0 {
1968 return Err(SyncwebError::InvalidConfig(format!("transfer job not found: {id}")));
1969 }
1970 drop(connection);
1971 Ok(())
1972 }
1973
1974 pub fn transition_transfer_job_state(
1980 &self,
1981 id: &str,
1982 expected_state: &str,
1983 state: &str,
1984 error_message: Option<&str>,
1985 ) -> Result<bool> {
1986 let connection = self
1987 .connection
1988 .lock()
1989 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1990 let now = current_timestamp().cast_signed();
1991 let updated = connection
1992 .execute(
1993 "UPDATE transfer_jobs
1994 SET state = ?1, error_message = ?2, updated_at = ?3
1995 WHERE id = ?4 AND state = ?5",
1996 params![state, error_message, now, id, expected_state],
1997 )
1998 .map_err(|error| SyncwebError::operation("failed to transition transfer job state", error))?;
1999 drop(connection);
2000 Ok(updated != 0)
2001 }
2002
2003 pub fn assign_transfer_job(&self, id: &str, root_id: &str, destination: &Path) -> Result<()> {
2009 let connection = self
2010 .connection
2011 .lock()
2012 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2013 let now = current_timestamp().cast_signed();
2014 let updated = connection
2015 .execute(
2016 "UPDATE transfer_jobs
2017 SET root_id = ?1, destination = ?2, updated_at = ?3
2018 WHERE id = ?4 AND state = 'queued'",
2019 params![root_id, destination.to_string_lossy().as_ref(), now, id],
2020 )
2021 .map_err(|error| SyncwebError::operation("failed to assign transfer job", error))?;
2022 if updated == 0 {
2023 return Err(SyncwebError::InvalidConfig(format!(
2024 "transfer job is missing or not queued: {id}"
2025 )));
2026 }
2027 drop(connection);
2028 Ok(())
2029 }
2030
2031 pub fn update_transfer_job_progress(
2037 &self,
2038 id: &str,
2039 bytes_transferred: u64,
2040 peer_count: u64,
2041 eta_seconds: Option<u64>,
2042 retries: u32,
2043 ) -> Result<()> {
2044 let connection = self
2045 .connection
2046 .lock()
2047 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2048 let now = current_timestamp().cast_signed();
2049 let updated = connection
2050 .execute(
2051 "UPDATE transfer_jobs
2052 SET bytes_transferred = ?1, peer_count = ?2, eta_seconds = ?3,
2053 retries = ?4, updated_at = ?5
2054 WHERE id = ?6",
2055 params![
2056 bytes_transferred.cast_signed(),
2057 peer_count.cast_signed(),
2058 eta_seconds.map(u64::cast_signed),
2059 retries.cast_signed(),
2060 now,
2061 id,
2062 ],
2063 )
2064 .map_err(|error| SyncwebError::operation("failed to update transfer job progress", error))?;
2065 if updated == 0 {
2066 return Err(SyncwebError::InvalidConfig(format!("transfer job not found: {id}")));
2067 }
2068 drop(connection);
2069 Ok(())
2070 }
2071
2072 pub fn retry_transfer_job(&self, id: &str) -> Result<()> {
2078 let connection = self
2079 .connection
2080 .lock()
2081 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2082 let now = current_timestamp().cast_signed();
2083 let updated = connection
2084 .execute(
2085 "UPDATE transfer_jobs
2086 SET state = 'queued', error_message = NULL,
2087 retries = retries + 1, updated_at = ?1
2088 WHERE id = ?2 AND state IN ('failed', 'paused', 'cancelled')",
2089 params![now, id],
2090 )
2091 .map_err(|error| SyncwebError::operation("failed to retry transfer job", error))?;
2092 if updated == 0 {
2093 return Err(SyncwebError::InvalidConfig(format!(
2094 "transfer job is missing or not retryable: {id}"
2095 )));
2096 }
2097 drop(connection);
2098 Ok(())
2099 }
2100
2101 pub fn recover_transfer_jobs(&self) -> Result<usize> {
2107 let connection = self
2108 .connection
2109 .lock()
2110 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2111 let now = current_timestamp().cast_signed();
2112 let recovered = connection
2113 .execute(
2114 "UPDATE transfer_jobs
2115 SET state = 'queued', updated_at = ?1
2116 WHERE state IN ('fetching', 'materializing')",
2117 params![now],
2118 )
2119 .map_err(|error| SyncwebError::operation("failed to recover transfer jobs", error))?;
2120 drop(connection);
2121 Ok(recovered)
2122 }
2123
2124 pub fn reserved_transfer_bytes(&self, root_id: &str) -> Result<u64> {
2130 let connection = self
2131 .connection
2132 .lock()
2133 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2134 let reserved: i64 = connection
2135 .query_row(
2136 "SELECT COALESCE(SUM(size), 0)
2137 FROM transfer_jobs
2138 WHERE root_id = ?1
2139 AND state IN ('queued', 'fetching', 'materializing', 'paused')",
2140 params![root_id],
2141 |row| row.get(0),
2142 )
2143 .map_err(|error| SyncwebError::operation("failed to query reserved transfer bytes", error))?;
2144 drop(connection);
2145 Ok(reserved.cast_unsigned())
2146 }
2147
2148 pub fn create_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
2158 let connection = self
2159 .connection
2160 .lock()
2161 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2162 let now = current_timestamp().cast_signed();
2163 connection
2164 .execute(
2165 "INSERT INTO sync_checkpoints(namespace_id, session_id, started_at, last_updated_at, status)
2166 VALUES (?1, ?2, ?3, ?4, 'running')",
2167 params![namespace_id, session_id, now, now],
2168 )
2169 .map_err(|error| SyncwebError::operation("failed to create sync checkpoint", error))?;
2170 drop(connection);
2171 Ok(())
2172 }
2173
2174 pub fn upsert_sync_entry(&self, p: &SyncEntryParams<'_>) -> Result<()> {
2180 let connection = self
2181 .connection
2182 .lock()
2183 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2184 let now = current_timestamp().cast_signed();
2185 connection
2186 .execute(
2187 "INSERT INTO sync_entry_progress(namespace_id, session_id, entry_key, hash, size, status, retries, error_message, updated_at)
2188 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2189 ON CONFLICT(namespace_id, session_id, entry_key) DO UPDATE SET
2190 status = excluded.status,
2191 retries = excluded.retries,
2192 error_message = excluded.error_message,
2193 updated_at = excluded.updated_at",
2194 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],
2195 )
2196 .map_err(|error| SyncwebError::operation("failed to upsert sync entry", error))?;
2197 drop(connection);
2198 Ok(())
2199 }
2200
2201 pub fn list_sync_entries(
2207 &self,
2208 namespace_id: &str,
2209 session_id: &str,
2210 status: &str,
2211 ) -> Result<Vec<crate::sync::checkpoint::EntryProgress>> {
2212 let connection = self
2213 .connection
2214 .lock()
2215 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2216 let mut stmt = connection
2217 .prepare(
2218 "SELECT entry_key, hash, size, status, retries, error_message
2219 FROM sync_entry_progress
2220 WHERE namespace_id = ?1 AND session_id = ?2 AND status = ?3",
2221 )
2222 .map_err(|error| SyncwebError::operation("failed to prepare sync entry query", error))?;
2223 let entries = stmt
2224 .query_map(params![namespace_id, session_id, status], |row| {
2225 let entry_key: Vec<u8> = row.get(0)?;
2226 let hash_bytes: Vec<u8> = row.get(1)?;
2227 let hash_str = String::from_utf8_lossy(&hash_bytes);
2228 let hash = hash_str.parse::<iroh_blobs::Hash>().unwrap_or(iroh_blobs::Hash::EMPTY);
2229 let size: i64 = row.get(2)?;
2230 let status_str: String = row.get(3)?;
2231 let retries: i64 = row.get(4)?;
2232 let error_message: Option<String> = row.get(5)?;
2233 Ok(crate::sync::checkpoint::EntryProgress {
2234 entry_key,
2235 hash,
2236 size: size.cast_unsigned(),
2237 status: status_str,
2238 retries: u32::try_from(retries).unwrap_or(0),
2239 error_message,
2240 })
2241 })
2242 .map_err(|error| SyncwebError::operation("failed to query sync entries", error))?
2243 .collect::<std::result::Result<Vec<_>, _>>()
2244 .map_err(|error| SyncwebError::operation("failed to read sync entry rows", error))?;
2245 drop(stmt);
2246 drop(connection);
2247 Ok(entries)
2248 }
2249
2250 pub fn get_checkpoint_progress(
2256 &self,
2257 namespace_id: &str,
2258 session_id: &str,
2259 ) -> Result<crate::sync::checkpoint::CheckpointProgress> {
2260 let connection = self
2261 .connection
2262 .lock()
2263 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2264 let (total, completed, failed, skipped, pending, bytes_transferred, bytes_total): (
2265 i64, i64, i64, i64, i64, i64, Option<i64>,
2266 ) = connection
2267 .query_row(
2268 "SELECT
2269 (SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2),
2270 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
2271 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'failed'), 0),
2272 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'skipped'), 0),
2273 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'pending'), 0),
2274 COALESCE((SELECT SUM(size) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
2275 (SELECT bytes_total FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2)",
2276 params![namespace_id, session_id],
2277 |row| Ok((
2278 row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?
2279 )),
2280 )
2281 .map_err(|error| SyncwebError::operation("failed to query checkpoint progress", error))?;
2282 drop(connection);
2283 let percentage = if total > 0 {
2284 let sum = completed.checked_add(skipped).unwrap_or(0);
2285 let pct_int = sum.checked_mul(100).and_then(|n| n.checked_div(total)).unwrap_or(0);
2286 f64::from(i32::try_from(pct_int).unwrap_or(0))
2287 } else {
2288 0.0
2289 };
2290 Ok(crate::sync::checkpoint::CheckpointProgress {
2291 total: usize::try_from(total).unwrap_or(0),
2292 completed: usize::try_from(completed).unwrap_or(0),
2293 failed: usize::try_from(failed).unwrap_or(0),
2294 skipped: usize::try_from(skipped).unwrap_or(0),
2295 pending: usize::try_from(pending).unwrap_or(0),
2296 bytes_transferred: bytes_transferred.cast_unsigned(),
2297 bytes_total: bytes_total.map(i64::cast_unsigned),
2298 percentage,
2299 })
2300 }
2301
2302 pub fn update_checkpoint_status(&self, namespace_id: &str, session_id: &str, status: &str) -> Result<()> {
2308 let connection = self
2309 .connection
2310 .lock()
2311 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2312 let now = current_timestamp().cast_signed();
2313 connection
2314 .execute(
2315 "UPDATE sync_checkpoints SET status = ?1, last_updated_at = ?2
2316 WHERE namespace_id = ?3 AND session_id = ?4",
2317 params![status, now, namespace_id, session_id],
2318 )
2319 .map_err(|error| SyncwebError::operation("failed to update checkpoint status", error))?;
2320 drop(connection);
2321 Ok(())
2322 }
2323
2324 pub fn find_unfinished_checkpoint(&self, namespace_id: &str) -> Result<Option<String>> {
2330 let connection = self
2331 .connection
2332 .lock()
2333 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2334 let result: Option<String> = connection
2335 .query_row(
2336 "SELECT session_id FROM sync_checkpoints
2337 WHERE namespace_id = ?1 AND status IN ('running', 'pending')
2338 ORDER BY started_at DESC LIMIT 1",
2339 params![namespace_id],
2340 |row| row.get(0),
2341 )
2342 .optional()
2343 .map_err(|error| SyncwebError::operation("failed to find unfinished checkpoint", error))?;
2344 drop(connection);
2345 Ok(result)
2346 }
2347
2348 pub fn delete_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
2354 let connection = self
2355 .connection
2356 .lock()
2357 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2358 connection
2359 .execute(
2360 "DELETE FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2",
2361 params![namespace_id, session_id],
2362 )
2363 .map_err(|error| SyncwebError::operation("failed to delete sync entry progress", error))?;
2364 connection
2365 .execute(
2366 "DELETE FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2",
2367 params![namespace_id, session_id],
2368 )
2369 .map_err(|error| SyncwebError::operation("failed to delete sync checkpoint", error))?;
2370 drop(connection);
2371 Ok(())
2372 }
2373
2374 pub fn clean_stale_checkpoints(&self, max_age_secs: i64) -> Result<usize> {
2380 let connection = self
2381 .connection
2382 .lock()
2383 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2384 let cutoff = current_timestamp().cast_signed().saturating_sub(max_age_secs);
2385 let deleted = connection
2386 .execute(
2387 "DELETE FROM sync_checkpoints WHERE last_updated_at < ?1 AND status NOT IN ('completed')",
2388 params![cutoff],
2389 )
2390 .map_err(|error| SyncwebError::operation("failed to clean stale checkpoints", error))?;
2391 drop(connection);
2392 Ok(deleted)
2393 }
2394
2395 pub fn vacuum(&self) -> Result<()> {
2405 let connection = self
2406 .connection
2407 .lock()
2408 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2409 connection
2410 .execute_batch("VACUUM")
2411 .map_err(|error| SyncwebError::operation("vacuum failed", error))?;
2412 drop(connection);
2413 Ok(())
2414 }
2415
2416 pub fn size_on_disk(&self) -> Result<u64> {
2422 let connection = self
2423 .connection
2424 .lock()
2425 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2426 let db_path: String = connection
2427 .query_row("PRAGMA database_list", [], |row| row.get(2))
2428 .map_err(|error| SyncwebError::operation("failed to query database path", error))?;
2429 drop(connection);
2430 std::fs::metadata(&db_path)
2431 .map(|meta| meta.len())
2432 .map_err(|error| SyncwebError::operation("failed to get database file size", error))
2433 }
2434
2435 pub fn freelist_count(&self) -> Result<i64> {
2441 let connection = self
2442 .connection
2443 .lock()
2444 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2445 let count: i64 = connection
2446 .pragma_query_value(None, "freelist_count", |row| row.get(0))
2447 .map_err(|error| SyncwebError::operation("failed to query freelist count", error))?;
2448 drop(connection);
2449 Ok(count)
2450 }
2451
2452 pub fn check_integrity(&self) -> Result<Vec<String>> {
2458 let connection = self
2459 .connection
2460 .lock()
2461 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2462 let mut stmt = connection
2463 .prepare("PRAGMA integrity_check")
2464 .map_err(|error| SyncwebError::operation("failed to prepare integrity check", error))?;
2465 let errors: Vec<String> = stmt
2466 .query_map([], |row| row.get::<_, String>(0))
2467 .map_err(|error| SyncwebError::operation("failed to run integrity check", error))?
2468 .filter_map(std::result::Result::ok)
2469 .filter(|s| s != "ok")
2470 .collect();
2471 drop(stmt);
2472 drop(connection);
2473 Ok(errors)
2474 }
2475}
2476
2477impl crate::storage::Vacuumable for NodeDatabase {
2478 fn vacuum(&self) -> Result<()> {
2479 self.vacuum()
2480 }
2481
2482 fn freelist_count(&self) -> Result<i64> {
2483 self.freelist_count()
2484 }
2485}
2486
2487#[cfg(test)]
2488mod tests {
2489 use super::*;
2490 use std::sync::Arc;
2491
2492 fn test_db() -> NodeDatabase {
2493 let conn = Connection::open_in_memory().unwrap();
2494 let db = NodeDatabase {
2495 connection: Arc::new(Mutex::new(conn)),
2496 };
2497 db.init_schema().unwrap();
2498 db
2499 }
2500
2501 fn make_public_key(seed: u8) -> PublicKey {
2502 iroh::SecretKey::from_bytes(&[seed; 32]).public()
2503 }
2504
2505 #[test]
2506 fn test_is_peer_network_member_true() {
2507 let db = test_db();
2508 let member_key = make_public_key(1);
2509 let owner = make_public_key(2);
2510
2511 let mut network = Network::new("test", owner, crate::net::network::NetworkOptions::default());
2512 network.members.insert(member_key);
2513 db.create_network(&network).unwrap();
2514
2515 assert!(db.is_peer_network_member(&member_key.to_string()).unwrap());
2516 }
2517
2518 #[test]
2519 fn test_is_peer_network_member_false() {
2520 let db = test_db();
2521 assert!(!db.is_peer_network_member("unknown-peer").unwrap());
2522 }
2523
2524 #[test]
2525 fn test_transfer_roots_and_jobs_survive_lifecycle_updates() {
2526 let db = test_db();
2527 db.upsert_storage_root(&StorageRootRecord {
2528 id: "root".to_owned(),
2529 path: PathBuf::from("/tmp/syncweb-root"),
2530 min_free: 10,
2531 enabled: true,
2532 })
2533 .unwrap();
2534 let hash = [7_u8; 32];
2535 let entry_key = b"dir/file.bin";
2536 let job_id = db
2537 .enqueue_transfer_job(&NewTransferJob {
2538 namespace_id: "namespace",
2539 entry_key,
2540 hash: &hash,
2541 size: 25,
2542 root_id: Some("root"),
2543 destination: Some(Path::new("/tmp/syncweb-root/namespace/dir/file.bin")),
2544 })
2545 .unwrap();
2546
2547 assert_eq!(db.reserved_transfer_bytes("root").unwrap(), 25);
2548 let jobs = db.list_transfer_jobs(Some("namespace"), Some("queued")).unwrap();
2549 assert_eq!(jobs.len(), 1);
2550 let job = jobs.first().expect("queued transfer job should be listed");
2551 assert_eq!(job.id, job_id);
2552 assert_eq!(job.hash, hash);
2553 assert_eq!(
2554 job.destination.as_deref(),
2555 Some(Path::new("/tmp/syncweb-root/namespace/dir/file.bin"))
2556 );
2557
2558 db.update_transfer_job_progress(&job_id, 12, 2, Some(4), 1).unwrap();
2559 db.update_transfer_job_state(&job_id, "materializing", None).unwrap();
2560 assert_eq!(db.recover_transfer_jobs().unwrap(), 1);
2561 let recovered = db.list_transfer_jobs(None, Some("queued")).unwrap();
2562 let recovered_job = recovered.first().expect("recovered transfer job should be listed");
2563 assert_eq!(recovered_job.bytes_transferred, 12);
2564 assert_eq!(recovered_job.retries, 1);
2565 }
2566
2567 #[test]
2568 fn test_assign_transfer_job_requires_queued_job() {
2569 let db = test_db();
2570 db.upsert_storage_root(&StorageRootRecord {
2571 id: "root".to_owned(),
2572 path: PathBuf::from("/tmp/syncweb-root"),
2573 min_free: 0,
2574 enabled: true,
2575 })
2576 .unwrap();
2577 let hash = [3_u8; 32];
2578 let job_id = db
2579 .enqueue_transfer_job(&NewTransferJob {
2580 namespace_id: "namespace",
2581 entry_key: b"file",
2582 hash: &hash,
2583 size: 1,
2584 root_id: None,
2585 destination: None,
2586 })
2587 .unwrap();
2588 db.assign_transfer_job(&job_id, "root", Path::new("/tmp/file")).unwrap();
2589 let jobs = db.list_transfer_jobs(None, None).unwrap();
2590 let job = jobs.first().expect("assigned transfer job should be listed");
2591 assert_eq!(job.root_id.as_deref(), Some("root"));
2592 }
2593}