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 networks (
221 id TEXT PRIMARY KEY,
222 name TEXT NOT NULL UNIQUE,
223 label TEXT NOT NULL DEFAULT '',
224 owner TEXT NOT NULL,
225 shared_secret BLOB,
226 created_at INTEGER NOT NULL
227 );
228 CREATE TABLE IF NOT EXISTS network_members (
229 network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
230 member TEXT NOT NULL,
231 PRIMARY KEY(network_id, member)
232 );
233 CREATE TABLE IF NOT EXISTS network_folders (
234 network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
235 namespace_id TEXT NOT NULL,
236 PRIMARY KEY(network_id, namespace_id)
237 );
238 CREATE TABLE IF NOT EXISTS installed_collections (
239 collection_id TEXT PRIMARY KEY,
240 manifest_hash TEXT NOT NULL,
241 current_version TEXT NOT NULL,
242 installed_at INTEGER NOT NULL
243 );
244 CREATE TABLE IF NOT EXISTS collection_versions (
245 collection_id TEXT NOT NULL REFERENCES installed_collections(collection_id) ON DELETE CASCADE,
246 version TEXT NOT NULL,
247 install_path TEXT NOT NULL,
248 PRIMARY KEY(collection_id, version)
249 );
250 CREATE TABLE IF NOT EXISTS app_config (
251 key TEXT PRIMARY KEY,
252 value TEXT NOT NULL,
253 updated_at INTEGER NOT NULL
254 );
255 CREATE TABLE IF NOT EXISTS filter_rules (
256 id INTEGER PRIMARY KEY AUTOINCREMENT,
257 namespace_id TEXT,
258 rule_type TEXT NOT NULL,
259 pattern TEXT NOT NULL,
260 priority INTEGER NOT NULL DEFAULT 0,
261 updated_at INTEGER NOT NULL
262 );
263 CREATE TABLE IF NOT EXISTS public_subscriptions (
264 hash TEXT PRIMARY KEY,
265 size INTEGER NOT NULL,
266 subscribed_at INTEGER NOT NULL
267 );
268 CREATE TABLE IF NOT EXISTS shares (
269 namespace_id TEXT NOT NULL,
270 access TEXT NOT NULL CHECK(access IN ('read','write')),
271 ticket TEXT NOT NULL,
272 created_at INTEGER NOT NULL,
273 PRIMARY KEY(namespace_id, access)
274 );
275 CREATE TABLE IF NOT EXISTS workspace_manifests (
276 source_path TEXT NOT NULL,
277 manifest_bytes BLOB NOT NULL,
278 collection_id TEXT NOT NULL,
279 updated_at INTEGER NOT NULL,
280 PRIMARY KEY(source_path)
281 );
282 CREATE TABLE IF NOT EXISTS blob_folders (
283 content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
284 namespace_id TEXT NOT NULL,
285 entry_key BLOB NOT NULL,
286 added_at INTEGER NOT NULL,
287 PRIMARY KEY(content_hash, namespace_id, entry_key)
288 );
289 CREATE INDEX IF NOT EXISTS idx_blob_folders_hash ON blob_folders(content_hash);
290 CREATE TABLE IF NOT EXISTS sync_checkpoints (
291 namespace_id TEXT NOT NULL,
292 session_id TEXT NOT NULL,
293 total_entries INTEGER NOT NULL DEFAULT 0,
294 processed_entries INTEGER NOT NULL DEFAULT 0,
295 failed_entries INTEGER NOT NULL DEFAULT 0,
296 bytes_total INTEGER,
297 bytes_transferred INTEGER NOT NULL DEFAULT 0,
298 started_at INTEGER NOT NULL,
299 last_updated_at INTEGER NOT NULL,
300 status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','completed','failed','cancelled')),
301 PRIMARY KEY(namespace_id, session_id)
302 );
303 CREATE TABLE IF NOT EXISTS sync_entry_progress (
304 namespace_id TEXT NOT NULL,
305 session_id TEXT NOT NULL,
306 entry_key BLOB NOT NULL,
307 hash BLOB NOT NULL,
308 size INTEGER NOT NULL,
309 status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','downloading','completed','failed','skipped')),
310 retries INTEGER NOT NULL DEFAULT 0,
311 error_message TEXT,
312 updated_at INTEGER NOT NULL,
313 PRIMARY KEY(namespace_id, session_id, entry_key),
314 FOREIGN KEY(namespace_id, session_id) REFERENCES sync_checkpoints(namespace_id, session_id) ON DELETE CASCADE
315 );
316 CREATE TABLE IF NOT EXISTS storage_roots (
317 id TEXT PRIMARY KEY,
318 path TEXT NOT NULL UNIQUE,
319 min_free INTEGER NOT NULL DEFAULT 0 CHECK(min_free >= 0),
320 enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
321 created_at INTEGER NOT NULL,
322 updated_at INTEGER NOT NULL
323 );
324 CREATE TABLE IF NOT EXISTS transfer_jobs (
325 id TEXT PRIMARY KEY,
326 namespace_id TEXT NOT NULL,
327 entry_key BLOB NOT NULL,
328 hash BLOB NOT NULL CHECK(length(hash) = 32),
329 size INTEGER NOT NULL CHECK(size >= 0),
330 root_id TEXT,
331 destination TEXT,
332 state TEXT NOT NULL DEFAULT 'queued'
333 CHECK(state IN ('queued','fetching','materializing','paused','completed','failed','cancelled')),
334 bytes_transferred INTEGER NOT NULL DEFAULT 0 CHECK(bytes_transferred >= 0),
335 peer_count INTEGER NOT NULL DEFAULT 0 CHECK(peer_count >= 0),
336 eta_seconds INTEGER,
337 retries INTEGER NOT NULL DEFAULT 0 CHECK(retries >= 0),
338 error_message TEXT,
339 created_at INTEGER NOT NULL,
340 updated_at INTEGER NOT NULL,
341 UNIQUE(namespace_id, entry_key, root_id),
342 FOREIGN KEY(root_id) REFERENCES storage_roots(id) ON DELETE SET NULL
343 );";
344
345 const fn create_schema_sql() -> &'static str {
347 Self::CREATE_SCHEMA_SQL
348 }
349
350 pub fn init_schema(&self) -> Result<()> {
356 let connection = self
357 .connection
358 .lock()
359 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
360 connection
361 .busy_timeout(std::time::Duration::from_secs(5))
362 .map_err(|error| SyncwebError::operation("failed to configure node database", error))?;
363 connection
364 .pragma_update(None, "journal_mode", "WAL")
365 .map_err(|error| SyncwebError::operation("failed to set WAL mode", error))?;
366 connection
367 .pragma_update(None, "foreign_keys", "ON")
368 .map_err(|error| SyncwebError::operation("failed to enable foreign keys", error))?;
369 connection
370 .execute_batch(Self::create_schema_sql())
371 .map_err(|error| SyncwebError::operation("failed to initialize node database schema", error))?;
372 if let Err(error) = connection.execute_batch("ALTER TABLE networks ADD COLUMN doc_ticket TEXT") {
373 tracing::debug!(%error, "migration: add doc_ticket (may already exist)");
374 }
375 if let Err(error) = connection.execute_batch(
376 "CREATE INDEX IF NOT EXISTS idx_transfer_jobs_namespace ON transfer_jobs(namespace_id);
377 CREATE INDEX IF NOT EXISTS idx_transfer_jobs_state ON transfer_jobs(state);
378 CREATE INDEX IF NOT EXISTS idx_transfer_jobs_root ON transfer_jobs(root_id);",
379 ) {
380 tracing::debug!(%error, "migration: add transfer job indexes (may already exist)");
381 }
382
383 connection
384 .execute(
385 "INSERT INTO schema_version(key, value) VALUES ('version', '1')
386 ON CONFLICT(key) DO NOTHING",
387 [],
388 )
389 .map_err(|error| SyncwebError::operation("failed to seed schema version", error))?;
390 drop(connection);
391 Ok(())
392 }
393
394 pub fn save_lifecycle(&self, state: &DaemonState) -> Result<()> {
400 let connection = self
401 .connection
402 .lock()
403 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
404 let status = match state.status {
405 DaemonStatus::Starting => "starting",
406 DaemonStatus::Running => "running",
407 DaemonStatus::Stopping => "stopping",
408 DaemonStatus::Stopped => "stopped",
409 };
410 let now = current_timestamp();
411 connection
412 .execute(
413 "INSERT INTO daemon_lifecycle(id, pid, node_id, started_at, status, data_dir, updated_at)
414 VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)
415 ON CONFLICT(id) DO UPDATE SET
416 pid = excluded.pid,
417 node_id = excluded.node_id,
418 started_at = excluded.started_at,
419 status = excluded.status,
420 data_dir = excluded.data_dir,
421 updated_at = excluded.updated_at",
422 params![
423 i64::from(state.pid),
424 state.node_id,
425 state.started_at.cast_signed(),
426 status,
427 state.data_dir.to_string_lossy().as_ref(),
428 now.cast_signed(),
429 ],
430 )
431 .map_err(|error| SyncwebError::operation("failed to save daemon lifecycle", error))?;
432 drop(connection);
433 Ok(())
434 }
435
436 pub fn load_lifecycle(&self) -> Result<Option<DaemonState>> {
442 let connection = self
443 .connection
444 .lock()
445 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
446 let result = connection
447 .query_row(
448 "SELECT pid, node_id, started_at, status, data_dir FROM daemon_lifecycle WHERE id = 1",
449 [],
450 |row| {
451 let pid: i64 = row.get(0)?;
452 let node_id: String = row.get(1)?;
453 let started_at: i64 = row.get(2)?;
454 let status_str: String = row.get(3)?;
455 let data_dir: String = row.get(4)?;
456 let status = match status_str.as_str() {
457 "starting" => DaemonStatus::Starting,
458 "running" => DaemonStatus::Running,
459 "stopping" => DaemonStatus::Stopping,
460 "stopped" => DaemonStatus::Stopped,
461 _ => {
462 return Err(rusqlite::Error::InvalidColumnType(
463 3,
464 "status".to_string(),
465 rusqlite::types::Type::Text,
466 ));
467 }
468 };
469 Ok(DaemonState::new(
470 u32::try_from(pid).unwrap_or_default(),
471 node_id,
472 started_at.cast_unsigned(),
473 data_dir,
474 status,
475 ))
476 },
477 )
478 .optional()
479 .map_err(|error| SyncwebError::operation("failed to load daemon lifecycle", error))?;
480 drop(connection);
481 Ok(result)
482 }
483
484 pub fn remove_lifecycle(&self) -> Result<()> {
490 let connection = self
491 .connection
492 .lock()
493 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
494 connection
495 .execute("DELETE FROM daemon_lifecycle WHERE id = 1", [])
496 .map_err(|error| SyncwebError::operation("failed to remove daemon lifecycle", error))?;
497 drop(connection);
498 Ok(())
499 }
500
501 pub fn save_status(&self, report: &DaemonStatusReport) -> Result<()> {
507 let connection = self
508 .connection
509 .lock()
510 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
511 let now = current_timestamp().cast_signed();
512 let has_schedule = report.schedule.is_some();
513 connection
514 .execute(
515 "INSERT INTO daemon_status(id, pid, node_id, started_at, uptime_seconds,
516 upload_total, download_total, upload_rate, download_rate,
517 has_schedule, in_active_window, next_window_start, rayon_threads, updated_at)
518 VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
519 ON CONFLICT(id) DO UPDATE SET
520 pid = excluded.pid,
521 node_id = excluded.node_id,
522 started_at = excluded.started_at,
523 uptime_seconds = excluded.uptime_seconds,
524 upload_total = excluded.upload_total,
525 download_total = excluded.download_total,
526 upload_rate = excluded.upload_rate,
527 download_rate = excluded.download_rate,
528 has_schedule = excluded.has_schedule,
529 in_active_window = excluded.in_active_window,
530 next_window_start = excluded.next_window_start,
531 rayon_threads = excluded.rayon_threads,
532 updated_at = excluded.updated_at",
533 params![
534 i64::from(report.pid),
535 report.node_id,
536 report.started_at.cast_signed(),
537 report.uptime_seconds.cast_signed(),
538 report.bandwidth.upload_total.cast_signed(),
539 report.bandwidth.download_total.cast_signed(),
540 report.bandwidth.upload_rate.cast_signed(),
541 report.bandwidth.download_rate.cast_signed(),
542 i64::from(has_schedule),
543 report.schedule.map_or(0, |s| i64::from(s.in_active_window)),
544 report.schedule.and_then(|s| s.next_window_start.map(u64::cast_signed)),
545 i64::try_from(report.rayon_threads).unwrap_or_default(),
546 now,
547 ],
548 )
549 .map_err(|error| SyncwebError::operation("failed to save daemon status", error))?;
550
551 for folder in &report.folders {
552 let errors_json = serde_json::to_string(&folder.errors).unwrap_or_else(|_| "[]".to_string());
553 connection
554 .execute(
555 "INSERT INTO folder_status_reports(namespace_id, path, session_active, last_sync_at,
556 entries_synced, errors_json, updated_at)
557 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
558 ON CONFLICT(namespace_id) DO UPDATE SET
559 path = excluded.path,
560 session_active = excluded.session_active,
561 last_sync_at = excluded.last_sync_at,
562 entries_synced = excluded.entries_synced,
563 errors_json = excluded.errors_json,
564 updated_at = excluded.updated_at",
565 params![
566 folder.namespace,
567 folder.path.to_string_lossy().as_ref(),
568 i64::from(folder.session_active),
569 folder.last_sync_at.map(u64::cast_signed),
570 folder.entries_synced.cast_signed(),
571 errors_json,
572 now,
573 ],
574 )
575 .map_err(|error| SyncwebError::operation("failed to save folder status", error))?;
576 }
577 drop(connection);
578 Ok(())
579 }
580
581 pub fn load_status(&self) -> Result<Option<DaemonStatusReport>> {
587 let connection = self
588 .connection
589 .lock()
590 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
591 let result: Option<DaemonStatusReport> = connection
592 .query_row(
593 "SELECT pid, node_id, started_at, uptime_seconds,
594 upload_total, download_total, upload_rate, download_rate,
595 has_schedule, in_active_window, next_window_start, rayon_threads
596 FROM daemon_status WHERE id = 1",
597 [],
598 |row| {
599 let pid: i64 = row.get(0)?;
600 let node_id: String = row.get(1)?;
601 let started_at: i64 = row.get(2)?;
602 let uptime_seconds: i64 = row.get(3)?;
603 let upload_total: i64 = row.get(4)?;
604 let download_total: i64 = row.get(5)?;
605 let upload_rate: i64 = row.get(6)?;
606 let download_rate: i64 = row.get(7)?;
607 let has_schedule: i64 = row.get(8)?;
608 let in_active_window: i64 = row.get(9)?;
609 let next_window_start: Option<i64> = row.get(10)?;
610 let rayon_threads: i64 = row.get(11)?;
611
612 let schedule = (has_schedule != 0).then(|| ScheduleStatus {
613 in_active_window: in_active_window != 0,
614 next_window_start: next_window_start.map(i64::cast_unsigned),
615 });
616
617 Ok(DaemonStatusReport {
618 pid: u32::try_from(pid).unwrap_or_default(),
619 node_id,
620 started_at: started_at.cast_unsigned(),
621 uptime_seconds: uptime_seconds.cast_unsigned(),
622 folders: Vec::new(),
623 bandwidth: BandwidthSnapshot {
624 upload_total: upload_total.cast_unsigned(),
625 download_total: download_total.cast_unsigned(),
626 upload_rate: upload_rate.cast_unsigned(),
627 download_rate: download_rate.cast_unsigned(),
628 },
629 schedule,
630 rayon_threads: usize::try_from(rayon_threads).unwrap_or_default(),
631 })
632 },
633 )
634 .optional()
635 .map_err(|error| SyncwebError::operation("failed to load daemon status", error))?;
636
637 if let Some(mut report) = result {
638 let mut folder_stmt = connection
639 .prepare(
640 "SELECT namespace_id, path, session_active, last_sync_at, entries_synced, errors_json
641 FROM folder_status_reports",
642 )
643 .map_err(|error| SyncwebError::operation("failed to prepare folder status query", error))?;
644 let folders: Vec<FolderStatusReport> = folder_stmt
645 .query_map([], |row| {
646 let namespace: String = row.get(0)?;
647 let path: String = row.get(1)?;
648 let session_active: i64 = row.get(2)?;
649 let last_sync_at: Option<i64> = row.get(3)?;
650 let entries_synced: i64 = row.get(4)?;
651 let errors_json: String = row.get(5)?;
652 let errors: Vec<String> = serde_json::from_str(&errors_json).unwrap_or_default();
653 Ok(FolderStatusReport::new(
654 namespace,
655 path,
656 session_active != 0,
657 last_sync_at.map(i64::cast_unsigned),
658 entries_synced.cast_unsigned(),
659 errors,
660 ))
661 })
662 .map_err(|error| SyncwebError::operation("failed to query folder statuses", error))?
663 .collect::<std::result::Result<Vec<_>, _>>()
664 .map_err(|error| SyncwebError::operation("failed to read folder status rows", error))?;
665 report.folders = folders;
666 drop(folder_stmt);
667 drop(connection);
668 Ok(Some(report))
669 } else {
670 drop(connection);
671 Ok(None)
672 }
673 }
674
675 pub fn remove_status(&self) -> Result<()> {
681 let connection = self
682 .connection
683 .lock()
684 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
685 connection
686 .execute("DELETE FROM daemon_status WHERE id = 1", [])
687 .map_err(|error| SyncwebError::operation("failed to remove daemon status", error))?;
688 connection
689 .execute("DELETE FROM folder_status_reports", [])
690 .map_err(|error| SyncwebError::operation("failed to remove folder status reports", error))?;
691 drop(connection);
692 Ok(())
693 }
694
695 pub fn create_network(&self, network: &Network) -> Result<()> {
701 let connection = self
702 .connection
703 .lock()
704 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
705 let now = current_timestamp().cast_signed();
706 connection.execute(
707 "INSERT INTO networks(id, name, label, owner, shared_secret, created_at, doc_ticket) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
708 params![
709 network.id.to_string(),
710 network.name,
711 network.label,
712 network.owner.to_string(),
713 network.shared_secret.as_ref().map(|s| s.to_vec()),
714 now,
715 network.doc_ticket,
716 ],
717 ).map_err(|error| SyncwebError::operation("failed to create network", error))?;
718 for member in &network.members {
719 connection
720 .execute(
721 "INSERT INTO network_members(network_id, member) VALUES (?1, ?2)",
722 params![network.id.to_string(), member.to_string()],
723 )
724 .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
725 }
726 for folder in &network.folders {
727 connection
728 .execute(
729 "INSERT INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
730 params![network.id.to_string(), folder.to_string()],
731 )
732 .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
733 }
734 drop(connection);
735 Ok(())
736 }
737
738 pub fn delete_network(&self, id: NetworkId) -> Result<()> {
744 let connection = self
745 .connection
746 .lock()
747 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
748 let affected = connection
749 .execute("DELETE FROM networks WHERE id = ?1", params![id.to_string()])
750 .map_err(|error| SyncwebError::operation("failed to delete network", error))?;
751 if affected == 0 {
752 drop(connection);
753 return Err(SyncwebError::FolderNotFound(format!("network {id}")));
754 }
755 drop(connection);
756 Ok(())
757 }
758
759 pub fn set_network_doc_ticket(&self, id: NetworkId, doc_ticket: &Option<String>) -> Result<()> {
765 let connection = self
766 .connection
767 .lock()
768 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
769 connection
770 .execute(
771 "UPDATE networks SET doc_ticket = ?1 WHERE id = ?2",
772 params![doc_ticket, id.to_string()],
773 )
774 .map_err(|error| SyncwebError::operation("failed to update network doc ticket", error))?;
775 drop(connection);
776 Ok(())
777 }
778
779 pub fn add_member(&self, network_id: NetworkId, member: PublicKey) -> Result<()> {
785 let connection = self
786 .connection
787 .lock()
788 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
789 connection
790 .execute(
791 "INSERT OR IGNORE INTO network_members(network_id, member) VALUES (?1, ?2)",
792 params![network_id.to_string(), member.to_string()],
793 )
794 .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
795 drop(connection);
796 Ok(())
797 }
798
799 pub fn remove_member(&self, network_id: NetworkId, member: &PublicKey) -> Result<bool> {
805 let connection = self
806 .connection
807 .lock()
808 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
809 let affected = connection
810 .execute(
811 "DELETE FROM network_members WHERE network_id = ?1 AND member = ?2",
812 params![network_id.to_string(), member.to_string()],
813 )
814 .map_err(|error| SyncwebError::operation("failed to remove network member", error))?;
815 drop(connection);
816 Ok(affected > 0)
817 }
818
819 pub fn add_folder_to_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
825 let connection = self
826 .connection
827 .lock()
828 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
829 connection
830 .execute(
831 "INSERT OR IGNORE INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
832 params![network_id.to_string(), namespace_id.to_string()],
833 )
834 .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
835 drop(connection);
836 Ok(())
837 }
838
839 pub fn remove_folder_from_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
845 let connection = self
846 .connection
847 .lock()
848 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
849 connection
850 .execute(
851 "DELETE FROM network_folders WHERE network_id = ?1 AND namespace_id = ?2",
852 params![network_id.to_string(), namespace_id.to_string()],
853 )
854 .map_err(|error| SyncwebError::operation("failed to remove network folder", error))?;
855 drop(connection);
856 Ok(())
857 }
858
859 pub fn list_networks(&self) -> Result<Vec<Network>> {
865 let connection = self
866 .connection
867 .lock()
868 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
869 let mut stmt = connection
870 .prepare("SELECT id, name, label, owner, shared_secret, doc_ticket FROM networks ORDER BY name")
871 .map_err(|error| SyncwebError::operation("failed to prepare network query", error))?;
872 let networks = stmt
873 .query_map([], |row| {
874 Ok((
875 row.get::<_, String>(0)?,
876 row.get::<_, String>(1)?,
877 row.get::<_, String>(2)?,
878 row.get::<_, String>(3)?,
879 row.get::<_, Option<Vec<u8>>>(4)?,
880 row.get::<_, Option<String>>(5)?,
881 ))
882 })
883 .map_err(|error| SyncwebError::operation("failed to query networks", error))?
884 .collect::<std::result::Result<Vec<_>, _>>()
885 .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
886
887 let mut member_stmt = connection
888 .prepare("SELECT member FROM network_members WHERE network_id = ?1")
889 .map_err(|error| SyncwebError::operation("failed to prepare member query", error))?;
890 let mut folder_stmt = connection
891 .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
892 .map_err(|error| SyncwebError::operation("failed to prepare folder query", error))?;
893
894 let mut result = Vec::new();
895 for (id_str, name, label, owner_str, shared_secret_bytes, doc_ticket) in networks {
896 let network_id = NetworkId::from_str(&id_str)
897 .map_err(|error| SyncwebError::operation("invalid network ID in database", error))?;
898 let owner = parse_public_key(&owner_str)?;
899 let shared_secret = shared_secret_bytes
900 .map(|bytes| -> Result<[u8; 32]> {
901 bytes.try_into().map_err(|_bytes: Vec<u8>| {
902 SyncwebError::operation("invalid shared secret length in database", "expected 32 bytes")
903 })
904 })
905 .transpose()?;
906
907 let members: HashSet<PublicKey> = member_stmt
908 .query_map(params![id_str], |row| row.get::<_, String>(0))
909 .map_err(|error| SyncwebError::operation("failed to query members", error))?
910 .collect::<std::result::Result<Vec<_>, _>>()
911 .map_err(|error| SyncwebError::operation("failed to read member rows", error))?
912 .into_iter()
913 .map(|s| parse_public_key(&s))
914 .collect::<Result<HashSet<_>>>()?;
915
916 let folders: HashSet<NamespaceId> = folder_stmt
917 .query_map(params![id_str], |row| row.get::<_, String>(0))
918 .map_err(|error| SyncwebError::operation("failed to query folders", error))?
919 .collect::<std::result::Result<Vec<_>, _>>()
920 .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?
921 .into_iter()
922 .map(|s| s.parse())
923 .collect::<std::result::Result<HashSet<_>, _>>()
924 .map_err(|error| SyncwebError::operation("invalid folder namespace in database", error))?;
925
926 result.push(Network {
927 id: network_id,
928 name,
929 label,
930 owner,
931 members,
932 folders,
933 shared_secret,
934 doc_ticket,
935 });
936 }
937 drop(stmt);
938 drop(member_stmt);
939 drop(folder_stmt);
940 drop(connection);
941 Ok(result)
942 }
943
944 pub fn get_collection_state(&self) -> Result<CollectionState> {
950 let connection = self
951 .connection
952 .lock()
953 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
954 let mut stmt = connection
955 .prepare("SELECT collection_id, manifest_hash, current_version FROM installed_collections")
956 .map_err(|error| SyncwebError::operation("failed to prepare collection query", error))?;
957 let collections: Vec<(String, String, String)> = stmt
958 .query_map([], |row| {
959 Ok((
960 row.get::<_, String>(0)?,
961 row.get::<_, String>(1)?,
962 row.get::<_, String>(2)?,
963 ))
964 })
965 .map_err(|error| SyncwebError::operation("failed to query collections", error))?
966 .collect::<std::result::Result<Vec<_>, _>>()
967 .map_err(|error| SyncwebError::operation("failed to read collection rows", error))?;
968
969 let mut version_stmt = connection
970 .prepare("SELECT version, install_path FROM collection_versions WHERE collection_id = ?1")
971 .map_err(|error| SyncwebError::operation("failed to prepare version query", error))?;
972
973 let mut installed = BTreeMap::new();
974 for (id_str, manifest_hash, current_version) in collections {
975 let collection_id = Uuid::from_str(&id_str)
976 .map_err(|error| SyncwebError::operation("invalid collection ID in database", error))?;
977 let manifest = iroh_blobs::Hash::from_str(&manifest_hash)
978 .map_err(|error| SyncwebError::operation("invalid manifest hash in database", error))?;
979
980 let versions: BTreeMap<String, PathBuf> = version_stmt
981 .query_map(params![id_str], |row| {
982 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
983 })
984 .map_err(|error| SyncwebError::operation("failed to query versions", error))?
985 .collect::<std::result::Result<Vec<_>, _>>()
986 .map_err(|error| SyncwebError::operation("failed to read version rows", error))?
987 .into_iter()
988 .map(|(v, p)| (v, PathBuf::from(p)))
989 .collect();
990
991 installed.insert(
992 collection_id,
993 InstalledCollection {
994 manifest,
995 versions,
996 current: current_version,
997 },
998 );
999 }
1000 drop(stmt);
1001 drop(version_stmt);
1002 drop(connection);
1003 Ok(CollectionState { installed })
1004 }
1005
1006 pub fn install_collection(
1012 &self,
1013 collection_id: Uuid,
1014 manifest_hash: &iroh_blobs::Hash,
1015 version: &str,
1016 path: &Path,
1017 ) -> Result<()> {
1018 let connection = self
1019 .connection
1020 .lock()
1021 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1022 let now = current_timestamp().cast_signed();
1023 connection
1024 .execute(
1025 "INSERT INTO installed_collections(collection_id, manifest_hash, current_version, installed_at)
1026 VALUES (?1, ?2, ?3, ?4)
1027 ON CONFLICT(collection_id) DO UPDATE SET
1028 manifest_hash = excluded.manifest_hash,
1029 current_version = excluded.current_version,
1030 installed_at = excluded.installed_at",
1031 params![collection_id.to_string(), manifest_hash.to_string(), version, now],
1032 )
1033 .map_err(|error| SyncwebError::operation("failed to install collection", error))?;
1034 connection
1035 .execute(
1036 "INSERT OR IGNORE INTO collection_versions(collection_id, version, install_path) VALUES (?1, ?2, ?3)",
1037 params![collection_id.to_string(), version, path.to_string_lossy().as_ref()],
1038 )
1039 .map_err(|error| SyncwebError::operation("failed to record collection version", error))?;
1040 drop(connection);
1041 Ok(())
1042 }
1043
1044 pub fn switch_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
1050 let connection = self
1051 .connection
1052 .lock()
1053 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1054 connection
1055 .execute(
1056 "UPDATE installed_collections SET current_version = ?1 WHERE collection_id = ?2",
1057 params![version, collection_id.to_string()],
1058 )
1059 .map_err(|error| SyncwebError::operation("failed to switch collection version", error))?;
1060 drop(connection);
1061 Ok(())
1062 }
1063
1064 pub fn remove_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
1070 let connection = self
1071 .connection
1072 .lock()
1073 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1074 connection
1075 .execute(
1076 "DELETE FROM collection_versions WHERE collection_id = ?1 AND version = ?2",
1077 params![collection_id.to_string(), version],
1078 )
1079 .map_err(|error| SyncwebError::operation("failed to remove collection version", error))?;
1080
1081 let remaining: i64 = connection
1082 .query_row(
1083 "SELECT COUNT(*) FROM collection_versions WHERE collection_id = ?1",
1084 params![collection_id.to_string()],
1085 |row| row.get(0),
1086 )
1087 .map_err(|error| SyncwebError::operation("failed to check remaining versions", error))?;
1088 if remaining == 0 {
1089 connection
1090 .execute(
1091 "DELETE FROM installed_collections WHERE collection_id = ?1",
1092 params![collection_id.to_string()],
1093 )
1094 .map_err(|error| SyncwebError::operation("failed to remove installed collection", error))?;
1095 }
1096 drop(connection);
1097 Ok(())
1098 }
1099
1100 pub fn get_config(&self, key: &str) -> Result<Option<String>> {
1106 let connection = self
1107 .connection
1108 .lock()
1109 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1110 let result = connection
1111 .query_row("SELECT value FROM app_config WHERE key = ?1", params![key], |row| {
1112 row.get::<_, String>(0)
1113 })
1114 .optional()
1115 .map_err(|error| SyncwebError::operation("failed to get config", error))?;
1116 drop(connection);
1117 Ok(result)
1118 }
1119
1120 pub fn set_config(&self, key: &str, value: &str) -> Result<()> {
1126 let connection = self
1127 .connection
1128 .lock()
1129 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1130 let now = current_timestamp().cast_signed();
1131 connection
1132 .execute(
1133 "INSERT INTO app_config(key, value, updated_at) VALUES (?1, ?2, ?3)
1134 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
1135 params![key, value, now],
1136 )
1137 .map_err(|error| SyncwebError::operation("failed to set config", error))?;
1138 drop(connection);
1139 Ok(())
1140 }
1141
1142 pub fn load_app_config(&self) -> Result<AppConfig> {
1148 let connection = self
1149 .connection
1150 .lock()
1151 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1152 let blob: Option<String> = connection
1153 .query_row("SELECT value FROM app_config WHERE key = 'config_blob'", [], |row| {
1154 row.get(0)
1155 })
1156 .optional()
1157 .map_err(|error| SyncwebError::operation("failed to load app config", error))?;
1158 drop(connection);
1159 blob.map_or_else(
1160 || Ok(AppConfig::default()),
1161 |toml_str| {
1162 toml::from_str(&toml_str).map_err(|error| SyncwebError::operation("failed to parse app config", error))
1163 },
1164 )
1165 }
1166
1167 pub fn save_app_config(&self, config: &AppConfig) -> Result<()> {
1173 let now = current_timestamp().cast_signed();
1174 let blob = toml::to_string_pretty(config)
1175 .map_err(|error| SyncwebError::operation("failed to serialize app config", error))?;
1176 let connection = self
1177 .connection
1178 .lock()
1179 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1180 connection
1181 .execute(
1182 "INSERT INTO app_config(key, value, updated_at) VALUES ('config_blob', ?1, ?2)
1183 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
1184 params![blob, now],
1185 )
1186 .map_err(|error| SyncwebError::operation("failed to save app config", error))?;
1187 drop(connection);
1188 Ok(())
1189 }
1190
1191 pub fn load_filter_engine(&self) -> Result<Option<FilterEngine>> {
1197 let connection = self
1198 .connection
1199 .lock()
1200 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1201 let mut stmt = connection
1202 .prepare("SELECT namespace_id, rule_type, pattern FROM filter_rules ORDER BY priority, id")
1203 .map_err(|error| SyncwebError::operation("failed to prepare filter query", error))?;
1204 let rows: Vec<(Option<String>, String, String)> = stmt
1205 .query_map([], |row| {
1206 Ok((
1207 row.get::<_, Option<String>>(0)?,
1208 row.get::<_, String>(1)?,
1209 row.get::<_, String>(2)?,
1210 ))
1211 })
1212 .map_err(|error| SyncwebError::operation("failed to query filters", error))?
1213 .collect::<std::result::Result<Vec<_>, _>>()
1214 .map_err(|error| SyncwebError::operation("failed to read filter rows", error))?;
1215 drop(stmt);
1216 drop(connection);
1217
1218 if rows.is_empty() {
1219 return Ok(None);
1220 }
1221
1222 let mut global_rules = Vec::new();
1223 let mut folder_rules: HashMap<String, Vec<FilterRule>> = HashMap::new();
1224 for (namespace_id, rule_type, pattern) in rows {
1225 let action = match rule_type.as_str() {
1226 "include" => crate::filter::FilterAction::Accept,
1227 "exclude" => crate::filter::FilterAction::Reject,
1228 _ => continue,
1229 };
1230 let criteria = crate::filter::MatchCriteria {
1231 name: Some(pattern),
1232 ..Default::default()
1233 };
1234 let rule = FilterRule::new(action, criteria);
1235 match namespace_id {
1236 Some(ns) => folder_rules.entry(ns).or_default().push(rule),
1237 None => global_rules.push(rule),
1238 }
1239 }
1240
1241 let filter_config = crate::filter::FilterConfig {
1242 rules: global_rules,
1243 folders: folder_rules,
1244 default_action: FilterAction::default(),
1245 };
1246 FilterEngine::new(filter_config).map(Some)
1247 }
1248
1249 pub fn save_filter_rules(&self, rules: &[FilterEntry]) -> Result<()> {
1255 let connection = self
1256 .connection
1257 .lock()
1258 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1259 let now = current_timestamp().cast_signed();
1260 connection
1261 .execute("DELETE FROM filter_rules", [])
1262 .map_err(|error| SyncwebError::operation("failed to clear filter rules", error))?;
1263 for entry in rules {
1264 let rule_type = if entry.size > 0 { "exclude" } else { "include" };
1265 connection
1266 .execute(
1267 "INSERT INTO filter_rules(namespace_id, rule_type, pattern, priority, updated_at)
1268 VALUES (NULL, ?1, ?2, 0, ?3)",
1269 params![rule_type, entry.path.to_string_lossy().as_ref(), now],
1270 )
1271 .map_err(|error| SyncwebError::operation("failed to save filter rule", error))?;
1272 }
1273 drop(connection);
1274 Ok(())
1275 }
1276
1277 pub fn load_subscriptions(&self) -> Result<HashSet<iroh_blobs::Hash>> {
1283 let connection = self
1284 .connection
1285 .lock()
1286 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1287 let mut stmt = connection
1288 .prepare("SELECT hash FROM public_subscriptions")
1289 .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1290 let hashes: HashSet<iroh_blobs::Hash> = stmt
1291 .query_map([], |row| {
1292 let text: String = row.get(0)?;
1293 Ok(text)
1294 })
1295 .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1296 .collect::<std::result::Result<Vec<_>, _>>()
1297 .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1298 .into_iter()
1299 .filter_map(|h| h.parse::<iroh_blobs::Hash>().ok())
1300 .collect();
1301 drop(stmt);
1302 drop(connection);
1303 Ok(hashes)
1304 }
1305
1306 pub fn load_subscriptions_with_sizes(&self) -> Result<Vec<(iroh_blobs::Hash, u64)>> {
1312 let connection = self
1313 .connection
1314 .lock()
1315 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1316 let mut stmt = connection
1317 .prepare("SELECT hash, size FROM public_subscriptions")
1318 .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1319 let results: Vec<(iroh_blobs::Hash, u64)> = stmt
1320 .query_map([], |row| {
1321 let text: String = row.get(0)?;
1322 let size: i64 = row.get(1)?;
1323 Ok((text, size))
1324 })
1325 .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1326 .collect::<std::result::Result<Vec<_>, _>>()
1327 .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1328 .into_iter()
1329 .filter_map(|(h, size)| {
1330 let hash = h.parse::<iroh_blobs::Hash>().ok()?;
1331 Some((hash, size.cast_unsigned()))
1332 })
1333 .collect();
1334 drop(stmt);
1335 drop(connection);
1336 Ok(results)
1337 }
1338
1339 pub fn save_subscription(&self, hash: &iroh_blobs::Hash, size: u64) -> Result<()> {
1345 let connection = self
1346 .connection
1347 .lock()
1348 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1349 let now = current_timestamp().cast_signed();
1350 connection
1351 .execute(
1352 "INSERT OR IGNORE INTO public_subscriptions(hash, size, subscribed_at) VALUES (?1, ?2, ?3)",
1353 params![hash.to_string(), size.cast_signed(), now],
1354 )
1355 .map_err(|error| SyncwebError::operation("failed to save subscription", error))?;
1356 drop(connection);
1357 Ok(())
1358 }
1359
1360 pub fn remove_subscription(&self, hash: &iroh_blobs::Hash) -> Result<()> {
1366 let connection = self
1367 .connection
1368 .lock()
1369 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1370 connection
1371 .execute(
1372 "DELETE FROM public_subscriptions WHERE hash = ?1",
1373 params![hash.to_string()],
1374 )
1375 .map_err(|error| SyncwebError::operation("failed to remove subscription", error))?;
1376 drop(connection);
1377 Ok(())
1378 }
1379
1380 pub fn add_share(&self, namespace_id: &str, access: &str, ticket: &str) -> Result<()> {
1390 let connection = self
1391 .connection
1392 .lock()
1393 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1394 connection
1395 .execute(
1396 "INSERT INTO shares (namespace_id, access, ticket, created_at)
1397 VALUES (?1, ?2, ?3, ?4)
1398 ON CONFLICT(namespace_id, access)
1399 DO UPDATE SET ticket = excluded.ticket",
1400 params![namespace_id, access, ticket, current_timestamp().cast_signed()],
1401 )
1402 .map_err(|error| SyncwebError::operation("failed to save share record", error))?;
1403 drop(connection);
1404 Ok(())
1405 }
1406
1407 pub fn remove_share(&self, namespace_id: &str, access: &str) -> Result<()> {
1413 let connection = self
1414 .connection
1415 .lock()
1416 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1417 connection
1418 .execute(
1419 "DELETE FROM shares WHERE namespace_id = ?1 AND access = ?2",
1420 params![namespace_id, access],
1421 )
1422 .map_err(|error| SyncwebError::operation("failed to remove share record", error))?;
1423 drop(connection);
1424 Ok(())
1425 }
1426
1427 pub fn list_shares(&self) -> Result<Vec<(String, String, String)>> {
1433 let connection = self
1434 .connection
1435 .lock()
1436 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1437 let mut stmt = connection
1438 .prepare("SELECT namespace_id, access, ticket FROM shares ORDER BY created_at")
1439 .map_err(|error| SyncwebError::operation("failed to prepare share query", error))?;
1440 let rows = stmt
1441 .query_map([], |row| {
1442 Ok((
1443 row.get::<_, String>(0)?,
1444 row.get::<_, String>(1)?,
1445 row.get::<_, String>(2)?,
1446 ))
1447 })
1448 .map_err(|error| SyncwebError::operation("failed to query shares", error))?
1449 .collect::<std::result::Result<Vec<_>, _>>()
1450 .map_err(|error| SyncwebError::operation("failed to read share rows", error))?;
1451 drop(stmt);
1452 drop(connection);
1453 Ok(rows)
1454 }
1455
1456 pub fn save_workspace_manifest(&self, source_path: &str, manifest_bytes: &[u8], collection_id: &str) -> Result<()> {
1466 let connection = self
1467 .connection
1468 .lock()
1469 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1470 let now = current_timestamp().cast_signed();
1471 connection
1472 .execute(
1473 "INSERT OR REPLACE INTO workspace_manifests(source_path, manifest_bytes, collection_id, updated_at)
1474 VALUES (?1, ?2, ?3, ?4)",
1475 params![source_path, manifest_bytes, collection_id, now],
1476 )
1477 .map_err(|error| SyncwebError::operation("failed to save workspace manifest", error))?;
1478 drop(connection);
1479 Ok(())
1480 }
1481
1482 pub fn load_workspace_manifest(&self, source_path: &str) -> Result<Option<Vec<u8>>> {
1488 let connection = self
1489 .connection
1490 .lock()
1491 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1492 let result: Option<Vec<u8>> = connection
1493 .query_row(
1494 "SELECT manifest_bytes FROM workspace_manifests WHERE source_path = ?1",
1495 params![source_path],
1496 |row| row.get(0),
1497 )
1498 .optional()
1499 .map_err(|error| SyncwebError::operation("failed to load workspace manifest", error))?;
1500 drop(connection);
1501 Ok(result)
1502 }
1503
1504 pub fn delete_workspace_manifest(&self, source_path: &str) -> Result<()> {
1510 let connection = self
1511 .connection
1512 .lock()
1513 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1514 connection
1515 .execute(
1516 "DELETE FROM workspace_manifests WHERE source_path = ?1",
1517 params![source_path],
1518 )
1519 .map_err(|error| SyncwebError::operation("failed to delete workspace manifest", error))?;
1520 drop(connection);
1521 Ok(())
1522 }
1523
1524 pub fn record_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> Result<()> {
1534 let connection = self
1535 .connection
1536 .lock()
1537 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1538 let now = current_timestamp().cast_signed();
1539 connection
1540 .execute(
1541 "INSERT OR IGNORE INTO blob_folders(content_hash, namespace_id, entry_key, added_at)
1542 VALUES (?1, ?2, ?3, ?4)",
1543 params![content_hash.as_slice(), namespace_id, entry_key, now,],
1544 )
1545 .map_err(|error| SyncwebError::operation("failed to record blob folder association", error))?;
1546 drop(connection);
1547 Ok(())
1548 }
1549
1550 pub fn remove_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> Result<()> {
1556 let connection = self
1557 .connection
1558 .lock()
1559 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1560 connection
1561 .execute(
1562 "DELETE FROM blob_folders WHERE content_hash = ?1 AND namespace_id = ?2 AND entry_key = ?3",
1563 params![content_hash.as_slice(), namespace_id, entry_key],
1564 )
1565 .map_err(|error| SyncwebError::operation("failed to remove blob folder association", error))?;
1566 drop(connection);
1567 Ok(())
1568 }
1569
1570 pub fn folders_for_network(&self, network_id: &str) -> Result<Vec<String>> {
1576 let connection = self
1577 .connection
1578 .lock()
1579 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1580 let mut stmt = connection
1581 .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
1582 .map_err(|error| SyncwebError::operation("failed to prepare folders query", error))?;
1583 let folders: Vec<String> = stmt
1584 .query_map(params![network_id], |row| row.get(0))
1585 .map_err(|error| SyncwebError::operation("failed to query folders", error))?
1586 .collect::<std::result::Result<Vec<_>, _>>()
1587 .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?;
1588 drop(stmt);
1589 drop(connection);
1590 Ok(folders)
1591 }
1592
1593 pub fn networks_for_folder(&self, namespace_id: &str) -> Result<Vec<String>> {
1599 let connection = self
1600 .connection
1601 .lock()
1602 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1603 let mut stmt = connection
1604 .prepare("SELECT network_id FROM network_folders WHERE namespace_id = ?1")
1605 .map_err(|error| SyncwebError::operation("failed to prepare networks query", error))?;
1606 let networks: Vec<String> = stmt
1607 .query_map(params![namespace_id], |row| row.get(0))
1608 .map_err(|error| SyncwebError::operation("failed to query networks", error))?
1609 .collect::<std::result::Result<Vec<_>, _>>()
1610 .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
1611 drop(stmt);
1612 drop(connection);
1613 Ok(networks)
1614 }
1615
1616 pub fn members_of_network(&self, network_id: &str) -> Result<Vec<String>> {
1622 let connection = self
1623 .connection
1624 .lock()
1625 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1626 let mut stmt = connection
1627 .prepare("SELECT member FROM network_members WHERE network_id = ?1")
1628 .map_err(|error| SyncwebError::operation("failed to prepare members query", error))?;
1629 let members: Vec<String> = stmt
1630 .query_map(params![network_id], |row| row.get(0))
1631 .map_err(|error| SyncwebError::operation("failed to query members", error))?
1632 .collect::<std::result::Result<Vec<_>, _>>()
1633 .map_err(|error| SyncwebError::operation("failed to read member rows", error))?;
1634 drop(stmt);
1635 drop(connection);
1636 Ok(members)
1637 }
1638
1639 pub fn is_peer_network_member(&self, peer_public_key: &str) -> Result<bool> {
1646 let connection = self
1647 .connection
1648 .lock()
1649 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1650 let exists: bool = connection
1651 .query_row(
1652 "SELECT 1 FROM network_members WHERE member = ?1 LIMIT 1",
1653 params![peer_public_key],
1654 |_| Ok(()),
1655 )
1656 .optional()
1657 .map_err(|error| SyncwebError::operation("failed to check peer membership", error))?
1658 .is_some();
1659 drop(connection);
1660 Ok(exists)
1661 }
1662
1663 pub fn upsert_storage_root(&self, root: &StorageRootRecord) -> Result<()> {
1673 let connection = self
1674 .connection
1675 .lock()
1676 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1677 let now = current_timestamp().cast_signed();
1678 connection
1679 .execute(
1680 "INSERT INTO storage_roots(id, path, min_free, enabled, created_at, updated_at)
1681 VALUES (?1, ?2, ?3, ?4, ?5, ?5)
1682 ON CONFLICT(id) DO UPDATE SET
1683 path = excluded.path,
1684 min_free = excluded.min_free,
1685 enabled = excluded.enabled,
1686 updated_at = excluded.updated_at",
1687 params![
1688 root.id,
1689 root.path.to_string_lossy().as_ref(),
1690 root.min_free.cast_signed(),
1691 i64::from(root.enabled),
1692 now,
1693 ],
1694 )
1695 .map_err(|error| SyncwebError::operation("failed to save storage root", error))?;
1696 drop(connection);
1697 Ok(())
1698 }
1699
1700 pub fn list_storage_roots(&self) -> Result<Vec<StorageRootRecord>> {
1706 let connection = self
1707 .connection
1708 .lock()
1709 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1710 let mut statement = connection
1711 .prepare("SELECT id, path, min_free, enabled FROM storage_roots ORDER BY id")
1712 .map_err(|error| SyncwebError::operation("failed to prepare storage root query", error))?;
1713 let roots = statement
1714 .query_map([], |row| {
1715 let min_free: i64 = row.get(2)?;
1716 let enabled: i64 = row.get(3)?;
1717 Ok(StorageRootRecord {
1718 id: row.get(0)?,
1719 path: PathBuf::from(row.get::<_, String>(1)?),
1720 min_free: min_free.cast_unsigned(),
1721 enabled: enabled != 0,
1722 })
1723 })
1724 .map_err(|error| SyncwebError::operation("failed to query storage roots", error))?
1725 .collect::<std::result::Result<Vec<_>, _>>()
1726 .map_err(|error| SyncwebError::operation("failed to read storage root rows", error))?;
1727 drop(statement);
1728 drop(connection);
1729 Ok(roots)
1730 }
1731
1732 pub fn remove_storage_root(&self, id: &str) -> Result<()> {
1738 let connection = self
1739 .connection
1740 .lock()
1741 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1742 connection
1743 .execute("DELETE FROM storage_roots WHERE id = ?1", params![id])
1744 .map_err(|error| SyncwebError::operation("failed to remove storage root", error))?;
1745 drop(connection);
1746 Ok(())
1747 }
1748
1749 pub fn enqueue_transfer_job(&self, job: &NewTransferJob<'_>) -> Result<String> {
1755 let id = Uuid::new_v4().to_string();
1756 let connection = self
1757 .connection
1758 .lock()
1759 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1760 let now = current_timestamp().cast_signed();
1761 connection
1762 .execute(
1763 "INSERT INTO transfer_jobs(
1764 id, namespace_id, entry_key, hash, size, root_id, destination,
1765 state, created_at, updated_at
1766 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'queued', ?8, ?8)",
1767 params![
1768 id,
1769 job.namespace_id,
1770 job.entry_key,
1771 job.hash.as_slice(),
1772 job.size.cast_signed(),
1773 job.root_id,
1774 job.destination.map(|path| path.to_string_lossy().into_owned()),
1775 now,
1776 ],
1777 )
1778 .map_err(|error| SyncwebError::operation("failed to enqueue transfer job", error))?;
1779 drop(connection);
1780 Ok(id)
1781 }
1782
1783 pub fn list_transfer_jobs(
1789 &self,
1790 namespace_id: Option<&str>,
1791 state: Option<&str>,
1792 ) -> Result<Vec<TransferJobRecord>> {
1793 let connection = self
1794 .connection
1795 .lock()
1796 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1797 let jobs = match (namespace_id, state) {
1798 (Some(namespace_filter), Some(state_filter)) => {
1799 let mut statement = connection
1800 .prepare(
1801 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1802 state, bytes_transferred, peer_count, eta_seconds, retries,
1803 error_message, created_at, updated_at
1804 FROM transfer_jobs
1805 WHERE namespace_id = ?1 AND state = ?2
1806 ORDER BY created_at, id",
1807 )
1808 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1809 statement
1810 .query_map(params![namespace_filter, state_filter], transfer_job_from_row)
1811 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1812 .collect::<std::result::Result<Vec<_>, _>>()
1813 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1814 }
1815 (Some(namespace_filter), None) => {
1816 let mut statement = connection
1817 .prepare(
1818 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1819 state, bytes_transferred, peer_count, eta_seconds, retries,
1820 error_message, created_at, updated_at
1821 FROM transfer_jobs
1822 WHERE namespace_id = ?1
1823 ORDER BY created_at, id",
1824 )
1825 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1826 statement
1827 .query_map(params![namespace_filter], transfer_job_from_row)
1828 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1829 .collect::<std::result::Result<Vec<_>, _>>()
1830 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1831 }
1832 (None, Some(state_filter)) => {
1833 let mut statement = connection
1834 .prepare(
1835 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1836 state, bytes_transferred, peer_count, eta_seconds, retries,
1837 error_message, created_at, updated_at
1838 FROM transfer_jobs
1839 WHERE state = ?1
1840 ORDER BY created_at, id",
1841 )
1842 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1843 statement
1844 .query_map(params![state_filter], transfer_job_from_row)
1845 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1846 .collect::<std::result::Result<Vec<_>, _>>()
1847 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1848 }
1849 (None, None) => {
1850 let mut statement = connection
1851 .prepare(
1852 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1853 state, bytes_transferred, peer_count, eta_seconds, retries,
1854 error_message, created_at, updated_at
1855 FROM transfer_jobs
1856 ORDER BY created_at, id",
1857 )
1858 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1859 statement
1860 .query_map([], transfer_job_from_row)
1861 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1862 .collect::<std::result::Result<Vec<_>, _>>()
1863 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1864 }
1865 };
1866 drop(connection);
1867 Ok(jobs)
1868 }
1869
1870 pub fn update_transfer_job_state(&self, id: &str, state: &str, error_message: Option<&str>) -> Result<()> {
1876 let connection = self
1877 .connection
1878 .lock()
1879 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1880 let now = current_timestamp().cast_signed();
1881 let updated = connection
1882 .execute(
1883 "UPDATE transfer_jobs
1884 SET state = ?1, error_message = ?2, updated_at = ?3
1885 WHERE id = ?4",
1886 params![state, error_message, now, id],
1887 )
1888 .map_err(|error| SyncwebError::operation("failed to update transfer job state", error))?;
1889 if updated == 0 {
1890 return Err(SyncwebError::InvalidConfig(format!("transfer job not found: {id}")));
1891 }
1892 drop(connection);
1893 Ok(())
1894 }
1895
1896 pub fn transition_transfer_job_state(
1902 &self,
1903 id: &str,
1904 expected_state: &str,
1905 state: &str,
1906 error_message: Option<&str>,
1907 ) -> Result<bool> {
1908 let connection = self
1909 .connection
1910 .lock()
1911 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1912 let now = current_timestamp().cast_signed();
1913 let updated = connection
1914 .execute(
1915 "UPDATE transfer_jobs
1916 SET state = ?1, error_message = ?2, updated_at = ?3
1917 WHERE id = ?4 AND state = ?5",
1918 params![state, error_message, now, id, expected_state],
1919 )
1920 .map_err(|error| SyncwebError::operation("failed to transition transfer job state", error))?;
1921 drop(connection);
1922 Ok(updated != 0)
1923 }
1924
1925 pub fn assign_transfer_job(&self, id: &str, root_id: &str, destination: &Path) -> Result<()> {
1931 let connection = self
1932 .connection
1933 .lock()
1934 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1935 let now = current_timestamp().cast_signed();
1936 let updated = connection
1937 .execute(
1938 "UPDATE transfer_jobs
1939 SET root_id = ?1, destination = ?2, updated_at = ?3
1940 WHERE id = ?4 AND state = 'queued'",
1941 params![root_id, destination.to_string_lossy().as_ref(), now, id],
1942 )
1943 .map_err(|error| SyncwebError::operation("failed to assign transfer job", error))?;
1944 if updated == 0 {
1945 return Err(SyncwebError::InvalidConfig(format!(
1946 "transfer job is missing or not queued: {id}"
1947 )));
1948 }
1949 drop(connection);
1950 Ok(())
1951 }
1952
1953 pub fn update_transfer_job_progress(
1959 &self,
1960 id: &str,
1961 bytes_transferred: u64,
1962 peer_count: u64,
1963 eta_seconds: Option<u64>,
1964 retries: u32,
1965 ) -> Result<()> {
1966 let connection = self
1967 .connection
1968 .lock()
1969 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1970 let now = current_timestamp().cast_signed();
1971 let updated = connection
1972 .execute(
1973 "UPDATE transfer_jobs
1974 SET bytes_transferred = ?1, peer_count = ?2, eta_seconds = ?3,
1975 retries = ?4, updated_at = ?5
1976 WHERE id = ?6",
1977 params![
1978 bytes_transferred.cast_signed(),
1979 peer_count.cast_signed(),
1980 eta_seconds.map(u64::cast_signed),
1981 retries.cast_signed(),
1982 now,
1983 id,
1984 ],
1985 )
1986 .map_err(|error| SyncwebError::operation("failed to update transfer job progress", error))?;
1987 if updated == 0 {
1988 return Err(SyncwebError::InvalidConfig(format!("transfer job not found: {id}")));
1989 }
1990 drop(connection);
1991 Ok(())
1992 }
1993
1994 pub fn retry_transfer_job(&self, id: &str) -> Result<()> {
2000 let connection = self
2001 .connection
2002 .lock()
2003 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2004 let now = current_timestamp().cast_signed();
2005 let updated = connection
2006 .execute(
2007 "UPDATE transfer_jobs
2008 SET state = 'queued', error_message = NULL,
2009 retries = retries + 1, updated_at = ?1
2010 WHERE id = ?2 AND state IN ('failed', 'paused', 'cancelled')",
2011 params![now, id],
2012 )
2013 .map_err(|error| SyncwebError::operation("failed to retry transfer job", error))?;
2014 if updated == 0 {
2015 return Err(SyncwebError::InvalidConfig(format!(
2016 "transfer job is missing or not retryable: {id}"
2017 )));
2018 }
2019 drop(connection);
2020 Ok(())
2021 }
2022
2023 pub fn recover_transfer_jobs(&self) -> Result<usize> {
2029 let connection = self
2030 .connection
2031 .lock()
2032 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2033 let now = current_timestamp().cast_signed();
2034 let recovered = connection
2035 .execute(
2036 "UPDATE transfer_jobs
2037 SET state = 'queued', updated_at = ?1
2038 WHERE state IN ('fetching', 'materializing')",
2039 params![now],
2040 )
2041 .map_err(|error| SyncwebError::operation("failed to recover transfer jobs", error))?;
2042 drop(connection);
2043 Ok(recovered)
2044 }
2045
2046 pub fn reserved_transfer_bytes(&self, root_id: &str) -> Result<u64> {
2052 let connection = self
2053 .connection
2054 .lock()
2055 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2056 let reserved: i64 = connection
2057 .query_row(
2058 "SELECT COALESCE(SUM(size), 0)
2059 FROM transfer_jobs
2060 WHERE root_id = ?1
2061 AND state IN ('queued', 'fetching', 'materializing', 'paused')",
2062 params![root_id],
2063 |row| row.get(0),
2064 )
2065 .map_err(|error| SyncwebError::operation("failed to query reserved transfer bytes", error))?;
2066 drop(connection);
2067 Ok(reserved.cast_unsigned())
2068 }
2069
2070 pub fn create_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
2080 let connection = self
2081 .connection
2082 .lock()
2083 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2084 let now = current_timestamp().cast_signed();
2085 connection
2086 .execute(
2087 "INSERT INTO sync_checkpoints(namespace_id, session_id, started_at, last_updated_at, status)
2088 VALUES (?1, ?2, ?3, ?4, 'running')",
2089 params![namespace_id, session_id, now, now],
2090 )
2091 .map_err(|error| SyncwebError::operation("failed to create sync checkpoint", error))?;
2092 drop(connection);
2093 Ok(())
2094 }
2095
2096 pub fn upsert_sync_entry(&self, p: &SyncEntryParams<'_>) -> Result<()> {
2102 let connection = self
2103 .connection
2104 .lock()
2105 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2106 let now = current_timestamp().cast_signed();
2107 connection
2108 .execute(
2109 "INSERT INTO sync_entry_progress(namespace_id, session_id, entry_key, hash, size, status, retries, error_message, updated_at)
2110 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2111 ON CONFLICT(namespace_id, session_id, entry_key) DO UPDATE SET
2112 status = excluded.status,
2113 retries = excluded.retries,
2114 error_message = excluded.error_message,
2115 updated_at = excluded.updated_at",
2116 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],
2117 )
2118 .map_err(|error| SyncwebError::operation("failed to upsert sync entry", error))?;
2119 drop(connection);
2120 Ok(())
2121 }
2122
2123 pub fn list_sync_entries(
2129 &self,
2130 namespace_id: &str,
2131 session_id: &str,
2132 status: &str,
2133 ) -> Result<Vec<crate::sync::checkpoint::EntryProgress>> {
2134 let connection = self
2135 .connection
2136 .lock()
2137 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2138 let mut stmt = connection
2139 .prepare(
2140 "SELECT entry_key, hash, size, status, retries, error_message
2141 FROM sync_entry_progress
2142 WHERE namespace_id = ?1 AND session_id = ?2 AND status = ?3",
2143 )
2144 .map_err(|error| SyncwebError::operation("failed to prepare sync entry query", error))?;
2145 let entries = stmt
2146 .query_map(params![namespace_id, session_id, status], |row| {
2147 let entry_key: Vec<u8> = row.get(0)?;
2148 let hash_bytes: Vec<u8> = row.get(1)?;
2149 let hash_str = String::from_utf8_lossy(&hash_bytes);
2150 let hash = hash_str.parse::<iroh_blobs::Hash>().unwrap_or(iroh_blobs::Hash::EMPTY);
2151 let size: i64 = row.get(2)?;
2152 let status_str: String = row.get(3)?;
2153 let retries: i64 = row.get(4)?;
2154 let error_message: Option<String> = row.get(5)?;
2155 Ok(crate::sync::checkpoint::EntryProgress {
2156 entry_key,
2157 hash,
2158 size: size.cast_unsigned(),
2159 status: status_str,
2160 retries: u32::try_from(retries).unwrap_or(0),
2161 error_message,
2162 })
2163 })
2164 .map_err(|error| SyncwebError::operation("failed to query sync entries", error))?
2165 .collect::<std::result::Result<Vec<_>, _>>()
2166 .map_err(|error| SyncwebError::operation("failed to read sync entry rows", error))?;
2167 drop(stmt);
2168 drop(connection);
2169 Ok(entries)
2170 }
2171
2172 pub fn get_checkpoint_progress(
2178 &self,
2179 namespace_id: &str,
2180 session_id: &str,
2181 ) -> Result<crate::sync::checkpoint::CheckpointProgress> {
2182 let connection = self
2183 .connection
2184 .lock()
2185 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2186 let (total, completed, failed, skipped, pending, bytes_transferred, bytes_total): (
2187 i64, i64, i64, i64, i64, i64, Option<i64>,
2188 ) = connection
2189 .query_row(
2190 "SELECT
2191 (SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2),
2192 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
2193 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'failed'), 0),
2194 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'skipped'), 0),
2195 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'pending'), 0),
2196 COALESCE((SELECT SUM(size) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
2197 (SELECT bytes_total FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2)",
2198 params![namespace_id, session_id],
2199 |row| Ok((
2200 row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?
2201 )),
2202 )
2203 .map_err(|error| SyncwebError::operation("failed to query checkpoint progress", error))?;
2204 drop(connection);
2205 let percentage = if total > 0 {
2206 let sum = completed.checked_add(skipped).unwrap_or(0);
2207 let pct_int = sum.checked_mul(100).and_then(|n| n.checked_div(total)).unwrap_or(0);
2208 f64::from(i32::try_from(pct_int).unwrap_or(0))
2209 } else {
2210 0.0
2211 };
2212 Ok(crate::sync::checkpoint::CheckpointProgress {
2213 total: usize::try_from(total).unwrap_or(0),
2214 completed: usize::try_from(completed).unwrap_or(0),
2215 failed: usize::try_from(failed).unwrap_or(0),
2216 skipped: usize::try_from(skipped).unwrap_or(0),
2217 pending: usize::try_from(pending).unwrap_or(0),
2218 bytes_transferred: bytes_transferred.cast_unsigned(),
2219 bytes_total: bytes_total.map(i64::cast_unsigned),
2220 percentage,
2221 })
2222 }
2223
2224 pub fn update_checkpoint_status(&self, namespace_id: &str, session_id: &str, status: &str) -> Result<()> {
2230 let connection = self
2231 .connection
2232 .lock()
2233 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2234 let now = current_timestamp().cast_signed();
2235 connection
2236 .execute(
2237 "UPDATE sync_checkpoints SET status = ?1, last_updated_at = ?2
2238 WHERE namespace_id = ?3 AND session_id = ?4",
2239 params![status, now, namespace_id, session_id],
2240 )
2241 .map_err(|error| SyncwebError::operation("failed to update checkpoint status", error))?;
2242 drop(connection);
2243 Ok(())
2244 }
2245
2246 pub fn find_unfinished_checkpoint(&self, namespace_id: &str) -> Result<Option<String>> {
2252 let connection = self
2253 .connection
2254 .lock()
2255 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2256 let result: Option<String> = connection
2257 .query_row(
2258 "SELECT session_id FROM sync_checkpoints
2259 WHERE namespace_id = ?1 AND status IN ('running', 'pending')
2260 ORDER BY started_at DESC LIMIT 1",
2261 params![namespace_id],
2262 |row| row.get(0),
2263 )
2264 .optional()
2265 .map_err(|error| SyncwebError::operation("failed to find unfinished checkpoint", error))?;
2266 drop(connection);
2267 Ok(result)
2268 }
2269
2270 pub fn delete_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
2276 let connection = self
2277 .connection
2278 .lock()
2279 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2280 connection
2281 .execute(
2282 "DELETE FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2",
2283 params![namespace_id, session_id],
2284 )
2285 .map_err(|error| SyncwebError::operation("failed to delete sync entry progress", error))?;
2286 connection
2287 .execute(
2288 "DELETE FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2",
2289 params![namespace_id, session_id],
2290 )
2291 .map_err(|error| SyncwebError::operation("failed to delete sync checkpoint", error))?;
2292 drop(connection);
2293 Ok(())
2294 }
2295
2296 pub fn clean_stale_checkpoints(&self, max_age_secs: i64) -> Result<usize> {
2302 let connection = self
2303 .connection
2304 .lock()
2305 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2306 let cutoff = current_timestamp().cast_signed().saturating_sub(max_age_secs);
2307 let deleted = connection
2308 .execute(
2309 "DELETE FROM sync_checkpoints WHERE last_updated_at < ?1 AND status NOT IN ('completed')",
2310 params![cutoff],
2311 )
2312 .map_err(|error| SyncwebError::operation("failed to clean stale checkpoints", error))?;
2313 drop(connection);
2314 Ok(deleted)
2315 }
2316
2317 pub fn vacuum(&self) -> Result<()> {
2327 let connection = self
2328 .connection
2329 .lock()
2330 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2331 connection
2332 .execute_batch("VACUUM")
2333 .map_err(|error| SyncwebError::operation("vacuum failed", error))?;
2334 drop(connection);
2335 Ok(())
2336 }
2337
2338 pub fn size_on_disk(&self) -> Result<u64> {
2344 let connection = self
2345 .connection
2346 .lock()
2347 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2348 let db_path: String = connection
2349 .query_row("PRAGMA database_list", [], |row| row.get(2))
2350 .map_err(|error| SyncwebError::operation("failed to query database path", error))?;
2351 drop(connection);
2352 std::fs::metadata(&db_path)
2353 .map(|meta| meta.len())
2354 .map_err(|error| SyncwebError::operation("failed to get database file size", error))
2355 }
2356
2357 pub fn freelist_count(&self) -> Result<i64> {
2363 let connection = self
2364 .connection
2365 .lock()
2366 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2367 let count: i64 = connection
2368 .pragma_query_value(None, "freelist_count", |row| row.get(0))
2369 .map_err(|error| SyncwebError::operation("failed to query freelist count", error))?;
2370 drop(connection);
2371 Ok(count)
2372 }
2373
2374 pub fn check_integrity(&self) -> Result<Vec<String>> {
2380 let connection = self
2381 .connection
2382 .lock()
2383 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2384 let mut stmt = connection
2385 .prepare("PRAGMA integrity_check")
2386 .map_err(|error| SyncwebError::operation("failed to prepare integrity check", error))?;
2387 let errors: Vec<String> = stmt
2388 .query_map([], |row| row.get::<_, String>(0))
2389 .map_err(|error| SyncwebError::operation("failed to run integrity check", error))?
2390 .filter_map(std::result::Result::ok)
2391 .filter(|s| s != "ok")
2392 .collect();
2393 drop(stmt);
2394 drop(connection);
2395 Ok(errors)
2396 }
2397}
2398
2399impl crate::storage::Vacuumable for NodeDatabase {
2400 fn vacuum(&self) -> Result<()> {
2401 self.vacuum()
2402 }
2403
2404 fn freelist_count(&self) -> Result<i64> {
2405 self.freelist_count()
2406 }
2407}
2408
2409#[cfg(test)]
2410mod tests {
2411 use super::*;
2412 use std::sync::Arc;
2413
2414 fn test_db() -> NodeDatabase {
2415 let conn = Connection::open_in_memory().unwrap();
2416 let db = NodeDatabase {
2417 connection: Arc::new(Mutex::new(conn)),
2418 };
2419 db.init_schema().unwrap();
2420 db
2421 }
2422
2423 fn make_public_key(seed: u8) -> PublicKey {
2424 iroh::SecretKey::from_bytes(&[seed; 32]).public()
2425 }
2426
2427 #[test]
2428 fn test_is_peer_network_member_true() {
2429 let db = test_db();
2430 let member_key = make_public_key(1);
2431 let owner = make_public_key(2);
2432
2433 let mut network = Network::new("test", owner, crate::net::network::NetworkOptions::default());
2434 network.members.insert(member_key);
2435 db.create_network(&network).unwrap();
2436
2437 assert!(db.is_peer_network_member(&member_key.to_string()).unwrap());
2438 }
2439
2440 #[test]
2441 fn test_is_peer_network_member_false() {
2442 let db = test_db();
2443 assert!(!db.is_peer_network_member("unknown-peer").unwrap());
2444 }
2445
2446 #[test]
2447 fn test_transfer_roots_and_jobs_survive_lifecycle_updates() {
2448 let db = test_db();
2449 db.upsert_storage_root(&StorageRootRecord {
2450 id: "root".to_owned(),
2451 path: PathBuf::from("/tmp/syncweb-root"),
2452 min_free: 10,
2453 enabled: true,
2454 })
2455 .unwrap();
2456 let hash = [7_u8; 32];
2457 let entry_key = b"dir/file.bin";
2458 let job_id = db
2459 .enqueue_transfer_job(&NewTransferJob {
2460 namespace_id: "namespace",
2461 entry_key,
2462 hash: &hash,
2463 size: 25,
2464 root_id: Some("root"),
2465 destination: Some(Path::new("/tmp/syncweb-root/namespace/dir/file.bin")),
2466 })
2467 .unwrap();
2468
2469 assert_eq!(db.reserved_transfer_bytes("root").unwrap(), 25);
2470 let jobs = db.list_transfer_jobs(Some("namespace"), Some("queued")).unwrap();
2471 assert_eq!(jobs.len(), 1);
2472 let job = jobs.first().expect("queued transfer job should be listed");
2473 assert_eq!(job.id, job_id);
2474 assert_eq!(job.hash, hash);
2475 assert_eq!(
2476 job.destination.as_deref(),
2477 Some(Path::new("/tmp/syncweb-root/namespace/dir/file.bin"))
2478 );
2479
2480 db.update_transfer_job_progress(&job_id, 12, 2, Some(4), 1).unwrap();
2481 db.update_transfer_job_state(&job_id, "materializing", None).unwrap();
2482 assert_eq!(db.recover_transfer_jobs().unwrap(), 1);
2483 let recovered = db.list_transfer_jobs(None, Some("queued")).unwrap();
2484 let recovered_job = recovered.first().expect("recovered transfer job should be listed");
2485 assert_eq!(recovered_job.bytes_transferred, 12);
2486 assert_eq!(recovered_job.retries, 1);
2487 }
2488
2489 #[test]
2490 fn test_assign_transfer_job_requires_queued_job() {
2491 let db = test_db();
2492 db.upsert_storage_root(&StorageRootRecord {
2493 id: "root".to_owned(),
2494 path: PathBuf::from("/tmp/syncweb-root"),
2495 min_free: 0,
2496 enabled: true,
2497 })
2498 .unwrap();
2499 let hash = [3_u8; 32];
2500 let job_id = db
2501 .enqueue_transfer_job(&NewTransferJob {
2502 namespace_id: "namespace",
2503 entry_key: b"file",
2504 hash: &hash,
2505 size: 1,
2506 root_id: None,
2507 destination: None,
2508 })
2509 .unwrap();
2510 db.assign_transfer_job(&job_id, "root", Path::new("/tmp/file")).unwrap();
2511 let jobs = db.list_transfer_jobs(None, None).unwrap();
2512 let job = jobs.first().expect("assigned transfer job should be listed");
2513 assert_eq!(job.root_id.as_deref(), Some("root"));
2514 }
2515}