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 kind TEXT NOT NULL DEFAULT 'folder',
214 mode TEXT NOT NULL DEFAULT '',
215 session_active INTEGER NOT NULL DEFAULT 0,
216 last_sync_at INTEGER,
217 sync_count INTEGER NOT NULL DEFAULT 0,
218 entries_synced INTEGER NOT NULL DEFAULT 0,
219 errors_json TEXT NOT NULL DEFAULT '[]',
220 updated_at INTEGER NOT NULL,
221 PRIMARY KEY(namespace_id)
222 );
223 CREATE TABLE IF NOT EXISTS folder_mounts (
224 namespace_id TEXT PRIMARY KEY,
225 path TEXT NOT NULL,
226 updated_at INTEGER NOT NULL
227 );
228 CREATE TABLE IF NOT EXISTS networks (
229 id TEXT PRIMARY KEY,
230 name TEXT NOT NULL UNIQUE,
231 label TEXT NOT NULL DEFAULT '',
232 owner TEXT NOT NULL,
233 shared_secret BLOB,
234 created_at INTEGER NOT NULL
235 );
236 CREATE TABLE IF NOT EXISTS network_members (
237 network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
238 member TEXT NOT NULL,
239 PRIMARY KEY(network_id, member)
240 );
241 CREATE TABLE IF NOT EXISTS network_folders (
242 network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
243 namespace_id TEXT NOT NULL,
244 PRIMARY KEY(network_id, namespace_id)
245 );
246 CREATE TABLE IF NOT EXISTS installed_collections (
247 collection_id TEXT PRIMARY KEY,
248 manifest_hash TEXT NOT NULL,
249 current_version TEXT NOT NULL,
250 installed_at INTEGER NOT NULL
251 );
252 CREATE TABLE IF NOT EXISTS collection_versions (
253 collection_id TEXT NOT NULL REFERENCES installed_collections(collection_id) ON DELETE CASCADE,
254 version TEXT NOT NULL,
255 install_path TEXT NOT NULL,
256 PRIMARY KEY(collection_id, version)
257 );
258 CREATE TABLE IF NOT EXISTS app_config (
259 key TEXT PRIMARY KEY,
260 value TEXT NOT NULL,
261 updated_at INTEGER NOT NULL
262 );
263 CREATE TABLE IF NOT EXISTS filter_rules (
264 id INTEGER PRIMARY KEY AUTOINCREMENT,
265 namespace_id TEXT,
266 rule_type TEXT NOT NULL,
267 pattern TEXT NOT NULL,
268 priority INTEGER NOT NULL DEFAULT 0,
269 updated_at INTEGER NOT NULL
270 );
271 CREATE TABLE IF NOT EXISTS folder_watch_config (
272 namespace_id TEXT PRIMARY KEY,
273 config TEXT NOT NULL,
274 updated_at INTEGER NOT NULL
275 );
276 CREATE TABLE IF NOT EXISTS public_subscriptions (
277 hash TEXT PRIMARY KEY,
278 size INTEGER NOT NULL,
279 subscribed_at INTEGER NOT NULL
280 );
281 CREATE TABLE IF NOT EXISTS shares (
282 namespace_id TEXT NOT NULL,
283 access TEXT NOT NULL CHECK(access IN ('read','write')),
284 ticket TEXT NOT NULL,
285 created_at INTEGER NOT NULL,
286 PRIMARY KEY(namespace_id, access)
287 );
288 CREATE TABLE IF NOT EXISTS workspace_manifests (
289 source_path TEXT NOT NULL,
290 manifest_bytes BLOB NOT NULL,
291 collection_id TEXT NOT NULL,
292 updated_at INTEGER NOT NULL,
293 PRIMARY KEY(source_path)
294 );
295 CREATE TABLE IF NOT EXISTS blob_folders (
296 content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
297 namespace_id TEXT NOT NULL,
298 entry_key BLOB NOT NULL,
299 added_at INTEGER NOT NULL,
300 PRIMARY KEY(content_hash, namespace_id, entry_key)
301 );
302 CREATE INDEX IF NOT EXISTS idx_blob_folders_hash ON blob_folders(content_hash);
303 CREATE TABLE IF NOT EXISTS sync_checkpoints (
304 namespace_id TEXT NOT NULL,
305 session_id TEXT NOT NULL,
306 total_entries INTEGER NOT NULL DEFAULT 0,
307 processed_entries INTEGER NOT NULL DEFAULT 0,
308 failed_entries INTEGER NOT NULL DEFAULT 0,
309 bytes_total INTEGER,
310 bytes_transferred INTEGER NOT NULL DEFAULT 0,
311 started_at INTEGER NOT NULL,
312 last_updated_at INTEGER NOT NULL,
313 status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','completed','failed','cancelled')),
314 PRIMARY KEY(namespace_id, session_id)
315 );
316 CREATE TABLE IF NOT EXISTS sync_entry_progress (
317 namespace_id TEXT NOT NULL,
318 session_id TEXT NOT NULL,
319 entry_key BLOB NOT NULL,
320 hash BLOB NOT NULL,
321 size INTEGER NOT NULL,
322 status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','downloading','completed','failed','skipped')),
323 retries INTEGER NOT NULL DEFAULT 0,
324 error_message TEXT,
325 updated_at INTEGER NOT NULL,
326 PRIMARY KEY(namespace_id, session_id, entry_key),
327 FOREIGN KEY(namespace_id, session_id) REFERENCES sync_checkpoints(namespace_id, session_id) ON DELETE CASCADE
328 );
329 CREATE TABLE IF NOT EXISTS storage_roots (
330 id TEXT PRIMARY KEY,
331 path TEXT NOT NULL UNIQUE,
332 min_free INTEGER NOT NULL DEFAULT 0 CHECK(min_free >= 0),
333 enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
334 created_at INTEGER NOT NULL,
335 updated_at INTEGER NOT NULL
336 );
337 CREATE TABLE IF NOT EXISTS transfer_jobs (
338 id TEXT PRIMARY KEY,
339 namespace_id TEXT NOT NULL,
340 entry_key BLOB NOT NULL,
341 hash BLOB NOT NULL CHECK(length(hash) = 32),
342 size INTEGER NOT NULL CHECK(size >= 0),
343 root_id TEXT,
344 destination TEXT,
345 state TEXT NOT NULL DEFAULT 'queued'
346 CHECK(state IN ('queued','fetching','materializing','paused','completed','failed','cancelled')),
347 bytes_transferred INTEGER NOT NULL DEFAULT 0 CHECK(bytes_transferred >= 0),
348 peer_count INTEGER NOT NULL DEFAULT 0 CHECK(peer_count >= 0),
349 eta_seconds INTEGER,
350 retries INTEGER NOT NULL DEFAULT 0 CHECK(retries >= 0),
351 error_message TEXT,
352 created_at INTEGER NOT NULL,
353 updated_at INTEGER NOT NULL,
354 UNIQUE(namespace_id, entry_key, root_id),
355 FOREIGN KEY(root_id) REFERENCES storage_roots(id) ON DELETE SET NULL
356 );";
357
358 const fn create_schema_sql() -> &'static str {
360 Self::CREATE_SCHEMA_SQL
361 }
362
363 pub fn init_schema(&self) -> Result<()> {
369 let connection = self
370 .connection
371 .lock()
372 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
373 connection
374 .busy_timeout(std::time::Duration::from_secs(5))
375 .map_err(|error| SyncwebError::operation("failed to configure node database", error))?;
376 connection
377 .pragma_update(None, "journal_mode", "WAL")
378 .map_err(|error| SyncwebError::operation("failed to set WAL mode", error))?;
379 connection
380 .pragma_update(None, "foreign_keys", "ON")
381 .map_err(|error| SyncwebError::operation("failed to enable foreign keys", error))?;
382 connection
383 .execute_batch(Self::create_schema_sql())
384 .map_err(|error| SyncwebError::operation("failed to initialize node database schema", error))?;
385 if let Err(error) = connection.execute_batch("ALTER TABLE networks ADD COLUMN doc_ticket TEXT") {
386 tracing::debug!(%error, "migration: add doc_ticket (may already exist)");
387 }
388 for migration in [
389 "ALTER TABLE folder_status_reports ADD COLUMN kind TEXT NOT NULL DEFAULT 'folder'",
390 "ALTER TABLE folder_status_reports ADD COLUMN mode TEXT NOT NULL DEFAULT ''",
391 "ALTER TABLE folder_status_reports ADD COLUMN sync_count INTEGER NOT NULL DEFAULT 0",
392 ] {
393 if let Err(error) = connection.execute_batch(migration) {
394 tracing::debug!(%error, "migration: {migration} (may already exist)");
395 }
396 }
397 if let Err(error) = connection.execute_batch(
398 "CREATE INDEX IF NOT EXISTS idx_transfer_jobs_namespace ON transfer_jobs(namespace_id);
399 CREATE INDEX IF NOT EXISTS idx_transfer_jobs_state ON transfer_jobs(state);
400 CREATE INDEX IF NOT EXISTS idx_transfer_jobs_root ON transfer_jobs(root_id);",
401 ) {
402 tracing::debug!(%error, "migration: add transfer job indexes (may already exist)");
403 }
404
405 connection
406 .execute(
407 "INSERT INTO schema_version(key, value) VALUES ('version', '1')
408 ON CONFLICT(key) DO NOTHING",
409 [],
410 )
411 .map_err(|error| SyncwebError::operation("failed to seed schema version", error))?;
412 drop(connection);
413 Ok(())
414 }
415
416 pub fn save_lifecycle(&self, state: &DaemonState) -> Result<()> {
422 let connection = self
423 .connection
424 .lock()
425 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
426 let status = match state.status {
427 DaemonStatus::Starting => "starting",
428 DaemonStatus::Running => "running",
429 DaemonStatus::Stopping => "stopping",
430 DaemonStatus::Stopped => "stopped",
431 };
432 let now = current_timestamp();
433 connection
434 .execute(
435 "INSERT INTO daemon_lifecycle(id, pid, node_id, started_at, status, data_dir, updated_at)
436 VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)
437 ON CONFLICT(id) DO UPDATE SET
438 pid = excluded.pid,
439 node_id = excluded.node_id,
440 started_at = excluded.started_at,
441 status = excluded.status,
442 data_dir = excluded.data_dir,
443 updated_at = excluded.updated_at",
444 params![
445 i64::from(state.pid),
446 state.node_id,
447 state.started_at.cast_signed(),
448 status,
449 state.data_dir.to_string_lossy().as_ref(),
450 now.cast_signed(),
451 ],
452 )
453 .map_err(|error| SyncwebError::operation("failed to save daemon lifecycle", error))?;
454 drop(connection);
455 Ok(())
456 }
457
458 pub fn load_lifecycle(&self) -> Result<Option<DaemonState>> {
464 let connection = self
465 .connection
466 .lock()
467 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
468 let result = connection
469 .query_row(
470 "SELECT pid, node_id, started_at, status, data_dir FROM daemon_lifecycle WHERE id = 1",
471 [],
472 |row| {
473 let pid: i64 = row.get(0)?;
474 let node_id: String = row.get(1)?;
475 let started_at: i64 = row.get(2)?;
476 let status_str: String = row.get(3)?;
477 let data_dir: String = row.get(4)?;
478 let status = match status_str.as_str() {
479 "starting" => DaemonStatus::Starting,
480 "running" => DaemonStatus::Running,
481 "stopping" => DaemonStatus::Stopping,
482 "stopped" => DaemonStatus::Stopped,
483 _ => {
484 return Err(rusqlite::Error::InvalidColumnType(
485 3,
486 "status".to_string(),
487 rusqlite::types::Type::Text,
488 ));
489 }
490 };
491 Ok(DaemonState::new(
492 u32::try_from(pid).unwrap_or_default(),
493 node_id,
494 started_at.cast_unsigned(),
495 data_dir,
496 status,
497 ))
498 },
499 )
500 .optional()
501 .map_err(|error| SyncwebError::operation("failed to load daemon lifecycle", error))?;
502 drop(connection);
503 Ok(result)
504 }
505
506 pub fn remove_lifecycle(&self) -> Result<()> {
512 let connection = self
513 .connection
514 .lock()
515 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
516 connection
517 .execute("DELETE FROM daemon_lifecycle WHERE id = 1", [])
518 .map_err(|error| SyncwebError::operation("failed to remove daemon lifecycle", error))?;
519 drop(connection);
520 Ok(())
521 }
522
523 pub fn save_status(&self, report: &DaemonStatusReport) -> Result<()> {
529 let connection = self
530 .connection
531 .lock()
532 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
533 let now = current_timestamp().cast_signed();
534 let has_schedule = report.schedule.is_some();
535 connection
536 .execute(
537 "INSERT INTO daemon_status(id, pid, node_id, started_at, uptime_seconds,
538 upload_total, download_total, upload_rate, download_rate,
539 has_schedule, in_active_window, next_window_start, rayon_threads, updated_at)
540 VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
541 ON CONFLICT(id) DO UPDATE SET
542 pid = excluded.pid,
543 node_id = excluded.node_id,
544 started_at = excluded.started_at,
545 uptime_seconds = excluded.uptime_seconds,
546 upload_total = excluded.upload_total,
547 download_total = excluded.download_total,
548 upload_rate = excluded.upload_rate,
549 download_rate = excluded.download_rate,
550 has_schedule = excluded.has_schedule,
551 in_active_window = excluded.in_active_window,
552 next_window_start = excluded.next_window_start,
553 rayon_threads = excluded.rayon_threads,
554 updated_at = excluded.updated_at",
555 params![
556 i64::from(report.pid),
557 report.node_id,
558 report.started_at.cast_signed(),
559 report.uptime_seconds.cast_signed(),
560 report.bandwidth.upload_total.cast_signed(),
561 report.bandwidth.download_total.cast_signed(),
562 report.bandwidth.upload_rate.cast_signed(),
563 report.bandwidth.download_rate.cast_signed(),
564 i64::from(has_schedule),
565 report.schedule.map_or(0, |s| i64::from(s.in_active_window)),
566 report.schedule.and_then(|s| s.next_window_start.map(u64::cast_signed)),
567 i64::try_from(report.rayon_threads).unwrap_or_default(),
568 now,
569 ],
570 )
571 .map_err(|error| SyncwebError::operation("failed to save daemon status", error))?;
572
573 for folder in &report.folders {
574 let errors_json = serde_json::to_string(&folder.errors).unwrap_or_else(|_| "[]".to_string());
575 connection
576 .execute(
577 "INSERT INTO folder_status_reports(namespace_id, path, kind, mode, session_active,
578 last_sync_at, sync_count, entries_synced, errors_json, updated_at)
579 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
580 ON CONFLICT(namespace_id) DO UPDATE SET
581 path = excluded.path,
582 kind = excluded.kind,
583 mode = excluded.mode,
584 session_active = excluded.session_active,
585 last_sync_at = excluded.last_sync_at,
586 sync_count = excluded.sync_count,
587 entries_synced = excluded.entries_synced,
588 errors_json = excluded.errors_json,
589 updated_at = excluded.updated_at",
590 params![
591 folder.namespace,
592 folder.path.to_string_lossy().as_ref(),
593 folder.kind,
594 folder.mode,
595 i64::from(folder.session_active),
596 folder.last_sync_at.map(u64::cast_signed),
597 folder.sync_count.cast_signed(),
598 folder.entries_synced.cast_signed(),
599 errors_json,
600 now,
601 ],
602 )
603 .map_err(|error| SyncwebError::operation("failed to save folder status", error))?;
604 }
605 drop(connection);
606 Ok(())
607 }
608
609 pub fn load_status(&self) -> Result<Option<DaemonStatusReport>> {
615 let connection = self
616 .connection
617 .lock()
618 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
619 let result: Option<DaemonStatusReport> = connection
620 .query_row(
621 "SELECT pid, node_id, started_at, uptime_seconds,
622 upload_total, download_total, upload_rate, download_rate,
623 has_schedule, in_active_window, next_window_start, rayon_threads
624 FROM daemon_status WHERE id = 1",
625 [],
626 |row| {
627 let pid: i64 = row.get(0)?;
628 let node_id: String = row.get(1)?;
629 let started_at: i64 = row.get(2)?;
630 let uptime_seconds: i64 = row.get(3)?;
631 let upload_total: i64 = row.get(4)?;
632 let download_total: i64 = row.get(5)?;
633 let upload_rate: i64 = row.get(6)?;
634 let download_rate: i64 = row.get(7)?;
635 let has_schedule: i64 = row.get(8)?;
636 let in_active_window: i64 = row.get(9)?;
637 let next_window_start: Option<i64> = row.get(10)?;
638 let rayon_threads: i64 = row.get(11)?;
639
640 let schedule = (has_schedule != 0).then(|| ScheduleStatus {
641 in_active_window: in_active_window != 0,
642 next_window_start: next_window_start.map(i64::cast_unsigned),
643 });
644
645 Ok(DaemonStatusReport {
646 pid: u32::try_from(pid).unwrap_or_default(),
647 node_id,
648 started_at: started_at.cast_unsigned(),
649 uptime_seconds: uptime_seconds.cast_unsigned(),
650 folders: Vec::new(),
651 bandwidth: BandwidthSnapshot {
652 upload_total: upload_total.cast_unsigned(),
653 download_total: download_total.cast_unsigned(),
654 upload_rate: upload_rate.cast_unsigned(),
655 download_rate: download_rate.cast_unsigned(),
656 },
657 schedule,
658 rayon_threads: usize::try_from(rayon_threads).unwrap_or_default(),
659 })
660 },
661 )
662 .optional()
663 .map_err(|error| SyncwebError::operation("failed to load daemon status", error))?;
664
665 if let Some(mut report) = result {
666 let mut folder_stmt = connection
667 .prepare(
668 "SELECT namespace_id, path, kind, mode, session_active, last_sync_at,
669 sync_count, entries_synced, errors_json
670 FROM folder_status_reports",
671 )
672 .map_err(|error| SyncwebError::operation("failed to prepare folder status query", error))?;
673 let folders: Vec<FolderStatusReport> = folder_stmt
674 .query_map([], |row| {
675 let namespace: String = row.get(0)?;
676 let path: String = row.get(1)?;
677 let kind: String = row.get(2)?;
678 let mode: String = row.get(3)?;
679 let session_active: i64 = row.get(4)?;
680 let last_sync_at: Option<i64> = row.get(5)?;
681 let sync_count: i64 = row.get(6)?;
682 let entries_synced: i64 = row.get(7)?;
683 let errors_json: String = row.get(8)?;
684 let errors: Vec<String> = serde_json::from_str(&errors_json).unwrap_or_default();
685 let mut folder = FolderStatusReport::new(
686 namespace,
687 path,
688 session_active != 0,
689 last_sync_at.map(i64::cast_unsigned),
690 entries_synced.cast_unsigned(),
691 errors,
692 );
693 folder.kind = if kind.is_empty() { folder.kind } else { kind };
694 folder.mode = mode;
695 folder.sync_count = sync_count.cast_unsigned();
696 Ok(folder)
697 })
698 .map_err(|error| SyncwebError::operation("failed to query folder statuses", error))?
699 .collect::<std::result::Result<Vec<_>, _>>()
700 .map_err(|error| SyncwebError::operation("failed to read folder status rows", error))?;
701 report.folders = folders;
702 drop(folder_stmt);
703 drop(connection);
704 Ok(Some(report))
705 } else {
706 drop(connection);
707 Ok(None)
708 }
709 }
710
711 pub fn remove_status(&self) -> Result<()> {
717 let connection = self
718 .connection
719 .lock()
720 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
721 connection
722 .execute("DELETE FROM daemon_status WHERE id = 1", [])
723 .map_err(|error| SyncwebError::operation("failed to remove daemon status", error))?;
724 connection
725 .execute("DELETE FROM folder_status_reports", [])
726 .map_err(|error| SyncwebError::operation("failed to remove folder status reports", error))?;
727 drop(connection);
728 Ok(())
729 }
730
731 pub fn upsert_folder_mount(&self, namespace_id: &str, path: &Path) -> Result<()> {
741 let connection = self
742 .connection
743 .lock()
744 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
745 let now = current_timestamp().cast_signed();
746 connection
747 .execute(
748 "INSERT INTO folder_mounts(namespace_id, path, updated_at) VALUES (?1, ?2, ?3)
749 ON CONFLICT(namespace_id) DO UPDATE SET
750 path = excluded.path,
751 updated_at = excluded.updated_at",
752 params![namespace_id, path.to_string_lossy().as_ref(), now],
753 )
754 .map_err(|error| SyncwebError::operation("failed to save folder mount", error))?;
755 drop(connection);
756 Ok(())
757 }
758
759 pub fn load_folder_mounts(&self) -> Result<Vec<(String, PathBuf)>> {
765 let connection = self
766 .connection
767 .lock()
768 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
769 let mut stmt = connection
770 .prepare("SELECT namespace_id, path FROM folder_mounts")
771 .map_err(|error| SyncwebError::operation("failed to prepare folder mount query", error))?;
772 let rows = stmt
773 .query_map([], |row| {
774 Ok((row.get::<_, String>(0)?, PathBuf::from(row.get::<_, String>(1)?)))
775 })
776 .map_err(|error| SyncwebError::operation("failed to query folder mounts", error))?
777 .collect::<std::result::Result<Vec<_>, _>>()
778 .map_err(|error| SyncwebError::operation("failed to read folder mount rows", error))?;
779 drop(stmt);
780 drop(connection);
781 Ok(rows)
782 }
783
784 pub fn remove_folder_mount(&self, namespace_id: &str) -> Result<()> {
790 let connection = self
791 .connection
792 .lock()
793 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
794 connection
795 .execute(
796 "DELETE FROM folder_mounts WHERE namespace_id = ?1",
797 params![namespace_id],
798 )
799 .map_err(|error| SyncwebError::operation("failed to remove folder mount", error))?;
800 drop(connection);
801 Ok(())
802 }
803
804 pub fn save_folder_watch_config(&self, namespace_id: &str, config: &str) -> Result<()> {
811 let connection = self
812 .connection
813 .lock()
814 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
815 let now = current_timestamp().cast_signed();
816 connection
817 .execute(
818 "INSERT INTO folder_watch_config(namespace_id, config, updated_at) VALUES (?1, ?2, ?3)
819 ON CONFLICT(namespace_id) DO UPDATE SET
820 config = excluded.config,
821 updated_at = excluded.updated_at",
822 params![namespace_id, config, now],
823 )
824 .map_err(|error| SyncwebError::operation("failed to save folder watch config", error))?;
825 drop(connection);
826 Ok(())
827 }
828
829 pub fn load_folder_watch_configs(&self) -> Result<Vec<(String, String)>> {
836 let connection = self
837 .connection
838 .lock()
839 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
840 let mut stmt = connection
841 .prepare("SELECT namespace_id, config FROM folder_watch_config")
842 .map_err(|error| SyncwebError::operation("failed to prepare folder watch config query", error))?;
843 let rows = stmt
844 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
845 .map_err(|error| SyncwebError::operation("failed to query folder watch configs", error))?
846 .collect::<std::result::Result<Vec<_>, _>>()
847 .map_err(|error| SyncwebError::operation("failed to read folder watch config rows", error))?;
848 drop(stmt);
849 drop(connection);
850 Ok(rows)
851 }
852
853 pub fn remove_folder_watch_config(&self, namespace_id: &str) -> Result<()> {
859 let connection = self
860 .connection
861 .lock()
862 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
863 connection
864 .execute(
865 "DELETE FROM folder_watch_config WHERE namespace_id = ?1",
866 params![namespace_id],
867 )
868 .map_err(|error| SyncwebError::operation("failed to remove folder watch config", error))?;
869 drop(connection);
870 Ok(())
871 }
872
873 pub fn create_network(&self, network: &Network) -> Result<()> {
879 let connection = self
880 .connection
881 .lock()
882 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
883 let now = current_timestamp().cast_signed();
884 connection.execute(
885 "INSERT INTO networks(id, name, label, owner, shared_secret, created_at, doc_ticket) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
886 params![
887 network.id.to_string(),
888 network.name,
889 network.label,
890 network.owner.to_string(),
891 network.shared_secret.as_ref().map(|s| s.to_vec()),
892 now,
893 network.doc_ticket,
894 ],
895 ).map_err(|error| SyncwebError::operation("failed to create network", error))?;
896 for member in &network.members {
897 connection
898 .execute(
899 "INSERT INTO network_members(network_id, member) VALUES (?1, ?2)",
900 params![network.id.to_string(), member.to_string()],
901 )
902 .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
903 }
904 for folder in &network.folders {
905 connection
906 .execute(
907 "INSERT INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
908 params![network.id.to_string(), folder.to_string()],
909 )
910 .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
911 }
912 drop(connection);
913 Ok(())
914 }
915
916 pub fn delete_network(&self, id: NetworkId) -> Result<()> {
922 let connection = self
923 .connection
924 .lock()
925 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
926 let affected = connection
927 .execute("DELETE FROM networks WHERE id = ?1", params![id.to_string()])
928 .map_err(|error| SyncwebError::operation("failed to delete network", error))?;
929 if affected == 0 {
930 drop(connection);
931 return Err(SyncwebError::FolderNotFound(format!("network {id}")));
932 }
933 drop(connection);
934 Ok(())
935 }
936
937 pub fn set_network_doc_ticket(&self, id: NetworkId, doc_ticket: &Option<String>) -> Result<()> {
943 let connection = self
944 .connection
945 .lock()
946 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
947 connection
948 .execute(
949 "UPDATE networks SET doc_ticket = ?1 WHERE id = ?2",
950 params![doc_ticket, id.to_string()],
951 )
952 .map_err(|error| SyncwebError::operation("failed to update network doc ticket", error))?;
953 drop(connection);
954 Ok(())
955 }
956
957 pub fn add_member(&self, network_id: NetworkId, member: PublicKey) -> Result<()> {
963 let connection = self
964 .connection
965 .lock()
966 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
967 connection
968 .execute(
969 "INSERT OR IGNORE INTO network_members(network_id, member) VALUES (?1, ?2)",
970 params![network_id.to_string(), member.to_string()],
971 )
972 .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
973 drop(connection);
974 Ok(())
975 }
976
977 pub fn remove_member(&self, network_id: NetworkId, member: &PublicKey) -> Result<bool> {
983 let connection = self
984 .connection
985 .lock()
986 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
987 let affected = connection
988 .execute(
989 "DELETE FROM network_members WHERE network_id = ?1 AND member = ?2",
990 params![network_id.to_string(), member.to_string()],
991 )
992 .map_err(|error| SyncwebError::operation("failed to remove network member", error))?;
993 drop(connection);
994 Ok(affected > 0)
995 }
996
997 pub fn add_folder_to_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
1003 let connection = self
1004 .connection
1005 .lock()
1006 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1007 connection
1008 .execute(
1009 "INSERT OR IGNORE INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
1010 params![network_id.to_string(), namespace_id.to_string()],
1011 )
1012 .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
1013 drop(connection);
1014 Ok(())
1015 }
1016
1017 pub fn remove_folder_from_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
1023 let connection = self
1024 .connection
1025 .lock()
1026 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1027 connection
1028 .execute(
1029 "DELETE FROM network_folders WHERE network_id = ?1 AND namespace_id = ?2",
1030 params![network_id.to_string(), namespace_id.to_string()],
1031 )
1032 .map_err(|error| SyncwebError::operation("failed to remove network folder", error))?;
1033 drop(connection);
1034 Ok(())
1035 }
1036
1037 pub fn list_networks(&self) -> Result<Vec<Network>> {
1043 let connection = self
1044 .connection
1045 .lock()
1046 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1047 let mut stmt = connection
1048 .prepare("SELECT id, name, label, owner, shared_secret, doc_ticket FROM networks ORDER BY name")
1049 .map_err(|error| SyncwebError::operation("failed to prepare network query", error))?;
1050 let networks = stmt
1051 .query_map([], |row| {
1052 Ok((
1053 row.get::<_, String>(0)?,
1054 row.get::<_, String>(1)?,
1055 row.get::<_, String>(2)?,
1056 row.get::<_, String>(3)?,
1057 row.get::<_, Option<Vec<u8>>>(4)?,
1058 row.get::<_, Option<String>>(5)?,
1059 ))
1060 })
1061 .map_err(|error| SyncwebError::operation("failed to query networks", error))?
1062 .collect::<std::result::Result<Vec<_>, _>>()
1063 .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
1064
1065 let mut member_stmt = connection
1066 .prepare("SELECT member FROM network_members WHERE network_id = ?1")
1067 .map_err(|error| SyncwebError::operation("failed to prepare member query", error))?;
1068 let mut folder_stmt = connection
1069 .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
1070 .map_err(|error| SyncwebError::operation("failed to prepare folder query", error))?;
1071
1072 let mut result = Vec::new();
1073 for (id_str, name, label, owner_str, shared_secret_bytes, doc_ticket) in networks {
1074 let network_id = NetworkId::from_str(&id_str)
1075 .map_err(|error| SyncwebError::operation("invalid network ID in database", error))?;
1076 let owner = parse_public_key(&owner_str)?;
1077 let shared_secret = shared_secret_bytes
1078 .map(|bytes| -> Result<[u8; 32]> {
1079 bytes.try_into().map_err(|_bytes: Vec<u8>| {
1080 SyncwebError::operation("invalid shared secret length in database", "expected 32 bytes")
1081 })
1082 })
1083 .transpose()?;
1084
1085 let members: HashSet<PublicKey> = member_stmt
1086 .query_map(params![id_str], |row| row.get::<_, String>(0))
1087 .map_err(|error| SyncwebError::operation("failed to query members", error))?
1088 .collect::<std::result::Result<Vec<_>, _>>()
1089 .map_err(|error| SyncwebError::operation("failed to read member rows", error))?
1090 .into_iter()
1091 .map(|s| parse_public_key(&s))
1092 .collect::<Result<HashSet<_>>>()?;
1093
1094 let folders: HashSet<NamespaceId> = folder_stmt
1095 .query_map(params![id_str], |row| row.get::<_, String>(0))
1096 .map_err(|error| SyncwebError::operation("failed to query folders", error))?
1097 .collect::<std::result::Result<Vec<_>, _>>()
1098 .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?
1099 .into_iter()
1100 .map(|s| s.parse())
1101 .collect::<std::result::Result<HashSet<_>, _>>()
1102 .map_err(|error| SyncwebError::operation("invalid folder namespace in database", error))?;
1103
1104 result.push(Network {
1105 id: network_id,
1106 name,
1107 label,
1108 owner,
1109 members,
1110 folders,
1111 shared_secret,
1112 doc_ticket,
1113 });
1114 }
1115 drop(stmt);
1116 drop(member_stmt);
1117 drop(folder_stmt);
1118 drop(connection);
1119 Ok(result)
1120 }
1121
1122 pub fn get_collection_state(&self) -> Result<CollectionState> {
1128 let connection = self
1129 .connection
1130 .lock()
1131 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1132 let mut stmt = connection
1133 .prepare("SELECT collection_id, manifest_hash, current_version FROM installed_collections")
1134 .map_err(|error| SyncwebError::operation("failed to prepare collection query", error))?;
1135 let collections: Vec<(String, String, String)> = stmt
1136 .query_map([], |row| {
1137 Ok((
1138 row.get::<_, String>(0)?,
1139 row.get::<_, String>(1)?,
1140 row.get::<_, String>(2)?,
1141 ))
1142 })
1143 .map_err(|error| SyncwebError::operation("failed to query collections", error))?
1144 .collect::<std::result::Result<Vec<_>, _>>()
1145 .map_err(|error| SyncwebError::operation("failed to read collection rows", error))?;
1146
1147 let mut version_stmt = connection
1148 .prepare("SELECT version, install_path FROM collection_versions WHERE collection_id = ?1")
1149 .map_err(|error| SyncwebError::operation("failed to prepare version query", error))?;
1150
1151 let mut installed = BTreeMap::new();
1152 for (id_str, manifest_hash, current_version) in collections {
1153 let collection_id = Uuid::from_str(&id_str)
1154 .map_err(|error| SyncwebError::operation("invalid collection ID in database", error))?;
1155 let manifest = iroh_blobs::Hash::from_str(&manifest_hash)
1156 .map_err(|error| SyncwebError::operation("invalid manifest hash in database", error))?;
1157
1158 let versions: BTreeMap<String, PathBuf> = version_stmt
1159 .query_map(params![id_str], |row| {
1160 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1161 })
1162 .map_err(|error| SyncwebError::operation("failed to query versions", error))?
1163 .collect::<std::result::Result<Vec<_>, _>>()
1164 .map_err(|error| SyncwebError::operation("failed to read version rows", error))?
1165 .into_iter()
1166 .map(|(v, p)| (v, PathBuf::from(p)))
1167 .collect();
1168
1169 installed.insert(
1170 collection_id,
1171 InstalledCollection {
1172 manifest,
1173 versions,
1174 current: current_version,
1175 },
1176 );
1177 }
1178 drop(stmt);
1179 drop(version_stmt);
1180 drop(connection);
1181 Ok(CollectionState { installed })
1182 }
1183
1184 pub fn install_collection(
1190 &self,
1191 collection_id: Uuid,
1192 manifest_hash: &iroh_blobs::Hash,
1193 version: &str,
1194 path: &Path,
1195 ) -> Result<()> {
1196 let connection = self
1197 .connection
1198 .lock()
1199 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1200 let now = current_timestamp().cast_signed();
1201 connection
1202 .execute(
1203 "INSERT INTO installed_collections(collection_id, manifest_hash, current_version, installed_at)
1204 VALUES (?1, ?2, ?3, ?4)
1205 ON CONFLICT(collection_id) DO UPDATE SET
1206 manifest_hash = excluded.manifest_hash,
1207 current_version = excluded.current_version,
1208 installed_at = excluded.installed_at",
1209 params![collection_id.to_string(), manifest_hash.to_string(), version, now],
1210 )
1211 .map_err(|error| SyncwebError::operation("failed to install collection", error))?;
1212 connection
1213 .execute(
1214 "INSERT OR IGNORE INTO collection_versions(collection_id, version, install_path) VALUES (?1, ?2, ?3)",
1215 params![collection_id.to_string(), version, path.to_string_lossy().as_ref()],
1216 )
1217 .map_err(|error| SyncwebError::operation("failed to record collection version", error))?;
1218 drop(connection);
1219 Ok(())
1220 }
1221
1222 pub fn switch_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
1228 let connection = self
1229 .connection
1230 .lock()
1231 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1232 connection
1233 .execute(
1234 "UPDATE installed_collections SET current_version = ?1 WHERE collection_id = ?2",
1235 params![version, collection_id.to_string()],
1236 )
1237 .map_err(|error| SyncwebError::operation("failed to switch collection version", error))?;
1238 drop(connection);
1239 Ok(())
1240 }
1241
1242 pub fn remove_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
1248 let connection = self
1249 .connection
1250 .lock()
1251 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1252 connection
1253 .execute(
1254 "DELETE FROM collection_versions WHERE collection_id = ?1 AND version = ?2",
1255 params![collection_id.to_string(), version],
1256 )
1257 .map_err(|error| SyncwebError::operation("failed to remove collection version", error))?;
1258
1259 let remaining: i64 = connection
1260 .query_row(
1261 "SELECT COUNT(*) FROM collection_versions WHERE collection_id = ?1",
1262 params![collection_id.to_string()],
1263 |row| row.get(0),
1264 )
1265 .map_err(|error| SyncwebError::operation("failed to check remaining versions", error))?;
1266 if remaining == 0 {
1267 connection
1268 .execute(
1269 "DELETE FROM installed_collections WHERE collection_id = ?1",
1270 params![collection_id.to_string()],
1271 )
1272 .map_err(|error| SyncwebError::operation("failed to remove installed collection", error))?;
1273 }
1274 drop(connection);
1275 Ok(())
1276 }
1277
1278 pub fn get_config(&self, key: &str) -> Result<Option<String>> {
1284 let connection = self
1285 .connection
1286 .lock()
1287 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1288 let result = connection
1289 .query_row("SELECT value FROM app_config WHERE key = ?1", params![key], |row| {
1290 row.get::<_, String>(0)
1291 })
1292 .optional()
1293 .map_err(|error| SyncwebError::operation("failed to get config", error))?;
1294 drop(connection);
1295 Ok(result)
1296 }
1297
1298 pub fn set_config(&self, key: &str, value: &str) -> Result<()> {
1304 let connection = self
1305 .connection
1306 .lock()
1307 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1308 let now = current_timestamp().cast_signed();
1309 connection
1310 .execute(
1311 "INSERT INTO app_config(key, value, updated_at) VALUES (?1, ?2, ?3)
1312 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
1313 params![key, value, now],
1314 )
1315 .map_err(|error| SyncwebError::operation("failed to set config", error))?;
1316 drop(connection);
1317 Ok(())
1318 }
1319
1320 pub fn load_app_config(&self) -> Result<AppConfig> {
1326 let connection = self
1327 .connection
1328 .lock()
1329 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1330 let blob: Option<String> = connection
1331 .query_row("SELECT value FROM app_config WHERE key = 'config_blob'", [], |row| {
1332 row.get(0)
1333 })
1334 .optional()
1335 .map_err(|error| SyncwebError::operation("failed to load app config", error))?;
1336 drop(connection);
1337 blob.map_or_else(
1338 || Ok(AppConfig::default()),
1339 |toml_str| {
1340 toml::from_str(&toml_str).map_err(|error| SyncwebError::operation("failed to parse app config", error))
1341 },
1342 )
1343 }
1344
1345 pub fn save_app_config(&self, config: &AppConfig) -> Result<()> {
1351 let now = current_timestamp().cast_signed();
1352 let blob = toml::to_string_pretty(config)
1353 .map_err(|error| SyncwebError::operation("failed to serialize app config", error))?;
1354 let connection = self
1355 .connection
1356 .lock()
1357 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1358 connection
1359 .execute(
1360 "INSERT INTO app_config(key, value, updated_at) VALUES ('config_blob', ?1, ?2)
1361 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
1362 params![blob, now],
1363 )
1364 .map_err(|error| SyncwebError::operation("failed to save app config", error))?;
1365 drop(connection);
1366 Ok(())
1367 }
1368
1369 pub fn load_filter_engine(&self) -> Result<Option<FilterEngine>> {
1375 let connection = self
1376 .connection
1377 .lock()
1378 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1379 let mut stmt = connection
1380 .prepare("SELECT namespace_id, rule_type, pattern FROM filter_rules ORDER BY priority, id")
1381 .map_err(|error| SyncwebError::operation("failed to prepare filter query", error))?;
1382 let rows: Vec<(Option<String>, String, String)> = stmt
1383 .query_map([], |row| {
1384 Ok((
1385 row.get::<_, Option<String>>(0)?,
1386 row.get::<_, String>(1)?,
1387 row.get::<_, String>(2)?,
1388 ))
1389 })
1390 .map_err(|error| SyncwebError::operation("failed to query filters", error))?
1391 .collect::<std::result::Result<Vec<_>, _>>()
1392 .map_err(|error| SyncwebError::operation("failed to read filter rows", error))?;
1393 drop(stmt);
1394 drop(connection);
1395
1396 if rows.is_empty() {
1397 return Ok(None);
1398 }
1399
1400 let mut global_rules = Vec::new();
1401 let mut folder_rules: HashMap<String, Vec<FilterRule>> = HashMap::new();
1402 for (namespace_id, rule_type, pattern) in rows {
1403 let action = match rule_type.as_str() {
1404 "include" => crate::filter::FilterAction::Accept,
1405 "exclude" => crate::filter::FilterAction::Reject,
1406 _ => continue,
1407 };
1408 let criteria = crate::filter::MatchCriteria {
1409 name: Some(pattern),
1410 ..Default::default()
1411 };
1412 let rule = FilterRule::new(action, criteria);
1413 match namespace_id {
1414 Some(ns) => folder_rules.entry(ns).or_default().push(rule),
1415 None => global_rules.push(rule),
1416 }
1417 }
1418
1419 let filter_config = crate::filter::FilterConfig {
1420 rules: global_rules,
1421 folders: folder_rules,
1422 default_action: FilterAction::default(),
1423 };
1424 FilterEngine::new(filter_config).map(Some)
1425 }
1426
1427 pub fn save_filter_rules(&self, rules: &[FilterEntry]) -> Result<()> {
1433 let connection = self
1434 .connection
1435 .lock()
1436 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1437 let now = current_timestamp().cast_signed();
1438 connection
1439 .execute("DELETE FROM filter_rules", [])
1440 .map_err(|error| SyncwebError::operation("failed to clear filter rules", error))?;
1441 for entry in rules {
1442 let rule_type = if entry.size > 0 { "exclude" } else { "include" };
1443 connection
1444 .execute(
1445 "INSERT INTO filter_rules(namespace_id, rule_type, pattern, priority, updated_at)
1446 VALUES (NULL, ?1, ?2, 0, ?3)",
1447 params![rule_type, entry.path.to_string_lossy().as_ref(), now],
1448 )
1449 .map_err(|error| SyncwebError::operation("failed to save filter rule", error))?;
1450 }
1451 drop(connection);
1452 Ok(())
1453 }
1454
1455 pub fn load_subscriptions(&self) -> Result<HashSet<iroh_blobs::Hash>> {
1461 let connection = self
1462 .connection
1463 .lock()
1464 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1465 let mut stmt = connection
1466 .prepare("SELECT hash FROM public_subscriptions")
1467 .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1468 let hashes: HashSet<iroh_blobs::Hash> = stmt
1469 .query_map([], |row| {
1470 let text: String = row.get(0)?;
1471 Ok(text)
1472 })
1473 .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1474 .collect::<std::result::Result<Vec<_>, _>>()
1475 .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1476 .into_iter()
1477 .filter_map(|h| h.parse::<iroh_blobs::Hash>().ok())
1478 .collect();
1479 drop(stmt);
1480 drop(connection);
1481 Ok(hashes)
1482 }
1483
1484 pub fn load_subscriptions_with_sizes(&self) -> Result<Vec<(iroh_blobs::Hash, u64)>> {
1490 let connection = self
1491 .connection
1492 .lock()
1493 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1494 let mut stmt = connection
1495 .prepare("SELECT hash, size FROM public_subscriptions")
1496 .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1497 let results: Vec<(iroh_blobs::Hash, u64)> = stmt
1498 .query_map([], |row| {
1499 let text: String = row.get(0)?;
1500 let size: i64 = row.get(1)?;
1501 Ok((text, size))
1502 })
1503 .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1504 .collect::<std::result::Result<Vec<_>, _>>()
1505 .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1506 .into_iter()
1507 .filter_map(|(h, size)| {
1508 let hash = h.parse::<iroh_blobs::Hash>().ok()?;
1509 Some((hash, size.cast_unsigned()))
1510 })
1511 .collect();
1512 drop(stmt);
1513 drop(connection);
1514 Ok(results)
1515 }
1516
1517 pub fn save_subscription(&self, hash: &iroh_blobs::Hash, size: u64) -> Result<()> {
1523 let connection = self
1524 .connection
1525 .lock()
1526 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1527 let now = current_timestamp().cast_signed();
1528 connection
1529 .execute(
1530 "INSERT OR IGNORE INTO public_subscriptions(hash, size, subscribed_at) VALUES (?1, ?2, ?3)",
1531 params![hash.to_string(), size.cast_signed(), now],
1532 )
1533 .map_err(|error| SyncwebError::operation("failed to save subscription", error))?;
1534 drop(connection);
1535 Ok(())
1536 }
1537
1538 pub fn remove_subscription(&self, hash: &iroh_blobs::Hash) -> Result<()> {
1544 let connection = self
1545 .connection
1546 .lock()
1547 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1548 connection
1549 .execute(
1550 "DELETE FROM public_subscriptions WHERE hash = ?1",
1551 params![hash.to_string()],
1552 )
1553 .map_err(|error| SyncwebError::operation("failed to remove subscription", error))?;
1554 drop(connection);
1555 Ok(())
1556 }
1557
1558 pub fn add_share(&self, namespace_id: &str, access: &str, ticket: &str) -> Result<()> {
1568 let connection = self
1569 .connection
1570 .lock()
1571 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1572 connection
1573 .execute(
1574 "INSERT INTO shares (namespace_id, access, ticket, created_at)
1575 VALUES (?1, ?2, ?3, ?4)
1576 ON CONFLICT(namespace_id, access)
1577 DO UPDATE SET ticket = excluded.ticket",
1578 params![namespace_id, access, ticket, current_timestamp().cast_signed()],
1579 )
1580 .map_err(|error| SyncwebError::operation("failed to save share record", error))?;
1581 drop(connection);
1582 Ok(())
1583 }
1584
1585 pub fn remove_share(&self, namespace_id: &str, access: &str) -> Result<()> {
1591 let connection = self
1592 .connection
1593 .lock()
1594 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1595 connection
1596 .execute(
1597 "DELETE FROM shares WHERE namespace_id = ?1 AND access = ?2",
1598 params![namespace_id, access],
1599 )
1600 .map_err(|error| SyncwebError::operation("failed to remove share record", error))?;
1601 drop(connection);
1602 Ok(())
1603 }
1604
1605 pub fn list_shares(&self) -> Result<Vec<(String, String, String)>> {
1611 let connection = self
1612 .connection
1613 .lock()
1614 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1615 let mut stmt = connection
1616 .prepare("SELECT namespace_id, access, ticket FROM shares ORDER BY created_at")
1617 .map_err(|error| SyncwebError::operation("failed to prepare share query", error))?;
1618 let rows = stmt
1619 .query_map([], |row| {
1620 Ok((
1621 row.get::<_, String>(0)?,
1622 row.get::<_, String>(1)?,
1623 row.get::<_, String>(2)?,
1624 ))
1625 })
1626 .map_err(|error| SyncwebError::operation("failed to query shares", error))?
1627 .collect::<std::result::Result<Vec<_>, _>>()
1628 .map_err(|error| SyncwebError::operation("failed to read share rows", error))?;
1629 drop(stmt);
1630 drop(connection);
1631 Ok(rows)
1632 }
1633
1634 pub fn save_workspace_manifest(&self, source_path: &str, manifest_bytes: &[u8], collection_id: &str) -> Result<()> {
1644 let connection = self
1645 .connection
1646 .lock()
1647 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1648 let now = current_timestamp().cast_signed();
1649 connection
1650 .execute(
1651 "INSERT OR REPLACE INTO workspace_manifests(source_path, manifest_bytes, collection_id, updated_at)
1652 VALUES (?1, ?2, ?3, ?4)",
1653 params![source_path, manifest_bytes, collection_id, now],
1654 )
1655 .map_err(|error| SyncwebError::operation("failed to save workspace manifest", error))?;
1656 drop(connection);
1657 Ok(())
1658 }
1659
1660 pub fn load_workspace_manifest(&self, source_path: &str) -> Result<Option<Vec<u8>>> {
1666 let connection = self
1667 .connection
1668 .lock()
1669 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1670 let result: Option<Vec<u8>> = connection
1671 .query_row(
1672 "SELECT manifest_bytes FROM workspace_manifests WHERE source_path = ?1",
1673 params![source_path],
1674 |row| row.get(0),
1675 )
1676 .optional()
1677 .map_err(|error| SyncwebError::operation("failed to load workspace manifest", error))?;
1678 drop(connection);
1679 Ok(result)
1680 }
1681
1682 pub fn delete_workspace_manifest(&self, source_path: &str) -> Result<()> {
1688 let connection = self
1689 .connection
1690 .lock()
1691 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1692 connection
1693 .execute(
1694 "DELETE FROM workspace_manifests WHERE source_path = ?1",
1695 params![source_path],
1696 )
1697 .map_err(|error| SyncwebError::operation("failed to delete workspace manifest", error))?;
1698 drop(connection);
1699 Ok(())
1700 }
1701
1702 pub fn record_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> Result<()> {
1712 let connection = self
1713 .connection
1714 .lock()
1715 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1716 let now = current_timestamp().cast_signed();
1717 connection
1718 .execute(
1719 "INSERT OR IGNORE INTO blob_folders(content_hash, namespace_id, entry_key, added_at)
1720 VALUES (?1, ?2, ?3, ?4)",
1721 params![content_hash.as_slice(), namespace_id, entry_key, now,],
1722 )
1723 .map_err(|error| SyncwebError::operation("failed to record blob folder association", error))?;
1724 drop(connection);
1725 Ok(())
1726 }
1727
1728 pub fn remove_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> Result<()> {
1734 let connection = self
1735 .connection
1736 .lock()
1737 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1738 connection
1739 .execute(
1740 "DELETE FROM blob_folders WHERE content_hash = ?1 AND namespace_id = ?2 AND entry_key = ?3",
1741 params![content_hash.as_slice(), namespace_id, entry_key],
1742 )
1743 .map_err(|error| SyncwebError::operation("failed to remove blob folder association", error))?;
1744 drop(connection);
1745 Ok(())
1746 }
1747
1748 pub fn folders_for_network(&self, network_id: &str) -> Result<Vec<String>> {
1754 let connection = self
1755 .connection
1756 .lock()
1757 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1758 let mut stmt = connection
1759 .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
1760 .map_err(|error| SyncwebError::operation("failed to prepare folders query", error))?;
1761 let folders: Vec<String> = stmt
1762 .query_map(params![network_id], |row| row.get(0))
1763 .map_err(|error| SyncwebError::operation("failed to query folders", error))?
1764 .collect::<std::result::Result<Vec<_>, _>>()
1765 .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?;
1766 drop(stmt);
1767 drop(connection);
1768 Ok(folders)
1769 }
1770
1771 pub fn networks_for_folder(&self, namespace_id: &str) -> Result<Vec<String>> {
1777 let connection = self
1778 .connection
1779 .lock()
1780 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1781 let mut stmt = connection
1782 .prepare("SELECT network_id FROM network_folders WHERE namespace_id = ?1")
1783 .map_err(|error| SyncwebError::operation("failed to prepare networks query", error))?;
1784 let networks: Vec<String> = stmt
1785 .query_map(params![namespace_id], |row| row.get(0))
1786 .map_err(|error| SyncwebError::operation("failed to query networks", error))?
1787 .collect::<std::result::Result<Vec<_>, _>>()
1788 .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
1789 drop(stmt);
1790 drop(connection);
1791 Ok(networks)
1792 }
1793
1794 pub fn members_of_network(&self, network_id: &str) -> Result<Vec<String>> {
1800 let connection = self
1801 .connection
1802 .lock()
1803 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1804 let mut stmt = connection
1805 .prepare("SELECT member FROM network_members WHERE network_id = ?1")
1806 .map_err(|error| SyncwebError::operation("failed to prepare members query", error))?;
1807 let members: Vec<String> = stmt
1808 .query_map(params![network_id], |row| row.get(0))
1809 .map_err(|error| SyncwebError::operation("failed to query members", error))?
1810 .collect::<std::result::Result<Vec<_>, _>>()
1811 .map_err(|error| SyncwebError::operation("failed to read member rows", error))?;
1812 drop(stmt);
1813 drop(connection);
1814 Ok(members)
1815 }
1816
1817 pub fn is_peer_network_member(&self, peer_public_key: &str) -> Result<bool> {
1824 let connection = self
1825 .connection
1826 .lock()
1827 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1828 let exists: bool = connection
1829 .query_row(
1830 "SELECT 1 FROM network_members WHERE member = ?1 LIMIT 1",
1831 params![peer_public_key],
1832 |_| Ok(()),
1833 )
1834 .optional()
1835 .map_err(|error| SyncwebError::operation("failed to check peer membership", error))?
1836 .is_some();
1837 drop(connection);
1838 Ok(exists)
1839 }
1840
1841 pub fn upsert_storage_root(&self, root: &StorageRootRecord) -> Result<()> {
1851 let connection = self
1852 .connection
1853 .lock()
1854 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1855 let now = current_timestamp().cast_signed();
1856 connection
1857 .execute(
1858 "INSERT INTO storage_roots(id, path, min_free, enabled, created_at, updated_at)
1859 VALUES (?1, ?2, ?3, ?4, ?5, ?5)
1860 ON CONFLICT(id) DO UPDATE SET
1861 path = excluded.path,
1862 min_free = excluded.min_free,
1863 enabled = excluded.enabled,
1864 updated_at = excluded.updated_at",
1865 params![
1866 root.id,
1867 root.path.to_string_lossy().as_ref(),
1868 root.min_free.cast_signed(),
1869 i64::from(root.enabled),
1870 now,
1871 ],
1872 )
1873 .map_err(|error| SyncwebError::operation("failed to save storage root", error))?;
1874 drop(connection);
1875 Ok(())
1876 }
1877
1878 pub fn list_storage_roots(&self) -> Result<Vec<StorageRootRecord>> {
1884 let connection = self
1885 .connection
1886 .lock()
1887 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1888 let mut statement = connection
1889 .prepare("SELECT id, path, min_free, enabled FROM storage_roots ORDER BY id")
1890 .map_err(|error| SyncwebError::operation("failed to prepare storage root query", error))?;
1891 let roots = statement
1892 .query_map([], |row| {
1893 let min_free: i64 = row.get(2)?;
1894 let enabled: i64 = row.get(3)?;
1895 Ok(StorageRootRecord {
1896 id: row.get(0)?,
1897 path: PathBuf::from(row.get::<_, String>(1)?),
1898 min_free: min_free.cast_unsigned(),
1899 enabled: enabled != 0,
1900 })
1901 })
1902 .map_err(|error| SyncwebError::operation("failed to query storage roots", error))?
1903 .collect::<std::result::Result<Vec<_>, _>>()
1904 .map_err(|error| SyncwebError::operation("failed to read storage root rows", error))?;
1905 drop(statement);
1906 drop(connection);
1907 Ok(roots)
1908 }
1909
1910 pub fn remove_storage_root(&self, id: &str) -> Result<()> {
1916 let connection = self
1917 .connection
1918 .lock()
1919 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1920 connection
1921 .execute("DELETE FROM storage_roots WHERE id = ?1", params![id])
1922 .map_err(|error| SyncwebError::operation("failed to remove storage root", error))?;
1923 drop(connection);
1924 Ok(())
1925 }
1926
1927 pub fn enqueue_transfer_job(&self, job: &NewTransferJob<'_>) -> Result<String> {
1933 let id = Uuid::new_v4().to_string();
1934 let connection = self
1935 .connection
1936 .lock()
1937 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1938 let now = current_timestamp().cast_signed();
1939 connection
1940 .execute(
1941 "INSERT INTO transfer_jobs(
1942 id, namespace_id, entry_key, hash, size, root_id, destination,
1943 state, created_at, updated_at
1944 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'queued', ?8, ?8)",
1945 params![
1946 id,
1947 job.namespace_id,
1948 job.entry_key,
1949 job.hash.as_slice(),
1950 job.size.cast_signed(),
1951 job.root_id,
1952 job.destination.map(|path| path.to_string_lossy().into_owned()),
1953 now,
1954 ],
1955 )
1956 .map_err(|error| SyncwebError::operation("failed to enqueue transfer job", error))?;
1957 drop(connection);
1958 Ok(id)
1959 }
1960
1961 pub fn list_transfer_jobs(
1967 &self,
1968 namespace_id: Option<&str>,
1969 state: Option<&str>,
1970 ) -> Result<Vec<TransferJobRecord>> {
1971 let connection = self
1972 .connection
1973 .lock()
1974 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1975 let jobs = match (namespace_id, state) {
1976 (Some(namespace_filter), Some(state_filter)) => {
1977 let mut statement = connection
1978 .prepare(
1979 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1980 state, bytes_transferred, peer_count, eta_seconds, retries,
1981 error_message, created_at, updated_at
1982 FROM transfer_jobs
1983 WHERE namespace_id = ?1 AND state = ?2
1984 ORDER BY created_at, id",
1985 )
1986 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1987 statement
1988 .query_map(params![namespace_filter, state_filter], transfer_job_from_row)
1989 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1990 .collect::<std::result::Result<Vec<_>, _>>()
1991 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1992 }
1993 (Some(namespace_filter), None) => {
1994 let mut statement = connection
1995 .prepare(
1996 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1997 state, bytes_transferred, peer_count, eta_seconds, retries,
1998 error_message, created_at, updated_at
1999 FROM transfer_jobs
2000 WHERE namespace_id = ?1
2001 ORDER BY created_at, id",
2002 )
2003 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
2004 statement
2005 .query_map(params![namespace_filter], transfer_job_from_row)
2006 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
2007 .collect::<std::result::Result<Vec<_>, _>>()
2008 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
2009 }
2010 (None, Some(state_filter)) => {
2011 let mut statement = connection
2012 .prepare(
2013 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
2014 state, bytes_transferred, peer_count, eta_seconds, retries,
2015 error_message, created_at, updated_at
2016 FROM transfer_jobs
2017 WHERE state = ?1
2018 ORDER BY created_at, id",
2019 )
2020 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
2021 statement
2022 .query_map(params![state_filter], transfer_job_from_row)
2023 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
2024 .collect::<std::result::Result<Vec<_>, _>>()
2025 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
2026 }
2027 (None, None) => {
2028 let mut statement = connection
2029 .prepare(
2030 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
2031 state, bytes_transferred, peer_count, eta_seconds, retries,
2032 error_message, created_at, updated_at
2033 FROM transfer_jobs
2034 ORDER BY created_at, id",
2035 )
2036 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
2037 statement
2038 .query_map([], transfer_job_from_row)
2039 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
2040 .collect::<std::result::Result<Vec<_>, _>>()
2041 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
2042 }
2043 };
2044 drop(connection);
2045 Ok(jobs)
2046 }
2047
2048 pub fn update_transfer_job_state(&self, id: &str, state: &str, error_message: Option<&str>) -> Result<()> {
2054 let connection = self
2055 .connection
2056 .lock()
2057 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2058 let now = current_timestamp().cast_signed();
2059 let updated = connection
2060 .execute(
2061 "UPDATE transfer_jobs
2062 SET state = ?1, error_message = ?2, updated_at = ?3
2063 WHERE id = ?4",
2064 params![state, error_message, now, id],
2065 )
2066 .map_err(|error| SyncwebError::operation("failed to update transfer job state", error))?;
2067 if updated == 0 {
2068 return Err(SyncwebError::InvalidConfig(format!("transfer job not found: {id}")));
2069 }
2070 drop(connection);
2071 Ok(())
2072 }
2073
2074 pub fn transition_transfer_job_state(
2080 &self,
2081 id: &str,
2082 expected_state: &str,
2083 state: &str,
2084 error_message: Option<&str>,
2085 ) -> Result<bool> {
2086 let connection = self
2087 .connection
2088 .lock()
2089 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2090 let now = current_timestamp().cast_signed();
2091 let updated = connection
2092 .execute(
2093 "UPDATE transfer_jobs
2094 SET state = ?1, error_message = ?2, updated_at = ?3
2095 WHERE id = ?4 AND state = ?5",
2096 params![state, error_message, now, id, expected_state],
2097 )
2098 .map_err(|error| SyncwebError::operation("failed to transition transfer job state", error))?;
2099 drop(connection);
2100 Ok(updated != 0)
2101 }
2102
2103 pub fn assign_transfer_job(&self, id: &str, root_id: &str, destination: &Path) -> Result<()> {
2109 let connection = self
2110 .connection
2111 .lock()
2112 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2113 let now = current_timestamp().cast_signed();
2114 let updated = connection
2115 .execute(
2116 "UPDATE transfer_jobs
2117 SET root_id = ?1, destination = ?2, updated_at = ?3
2118 WHERE id = ?4 AND state = 'queued'",
2119 params![root_id, destination.to_string_lossy().as_ref(), now, id],
2120 )
2121 .map_err(|error| SyncwebError::operation("failed to assign transfer job", error))?;
2122 if updated == 0 {
2123 return Err(SyncwebError::InvalidConfig(format!(
2124 "transfer job is missing or not queued: {id}"
2125 )));
2126 }
2127 drop(connection);
2128 Ok(())
2129 }
2130
2131 pub fn update_transfer_job_progress(
2137 &self,
2138 id: &str,
2139 bytes_transferred: u64,
2140 peer_count: u64,
2141 eta_seconds: Option<u64>,
2142 retries: u32,
2143 ) -> Result<()> {
2144 let connection = self
2145 .connection
2146 .lock()
2147 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2148 let now = current_timestamp().cast_signed();
2149 let updated = connection
2150 .execute(
2151 "UPDATE transfer_jobs
2152 SET bytes_transferred = ?1, peer_count = ?2, eta_seconds = ?3,
2153 retries = ?4, updated_at = ?5
2154 WHERE id = ?6",
2155 params![
2156 bytes_transferred.cast_signed(),
2157 peer_count.cast_signed(),
2158 eta_seconds.map(u64::cast_signed),
2159 retries.cast_signed(),
2160 now,
2161 id,
2162 ],
2163 )
2164 .map_err(|error| SyncwebError::operation("failed to update transfer job progress", error))?;
2165 if updated == 0 {
2166 return Err(SyncwebError::InvalidConfig(format!("transfer job not found: {id}")));
2167 }
2168 drop(connection);
2169 Ok(())
2170 }
2171
2172 pub fn retry_transfer_job(&self, id: &str) -> Result<()> {
2178 let connection = self
2179 .connection
2180 .lock()
2181 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2182 let now = current_timestamp().cast_signed();
2183 let updated = connection
2184 .execute(
2185 "UPDATE transfer_jobs
2186 SET state = 'queued', error_message = NULL,
2187 retries = retries + 1, updated_at = ?1
2188 WHERE id = ?2 AND state IN ('failed', 'paused', 'cancelled')",
2189 params![now, id],
2190 )
2191 .map_err(|error| SyncwebError::operation("failed to retry transfer job", error))?;
2192 if updated == 0 {
2193 return Err(SyncwebError::InvalidConfig(format!(
2194 "transfer job is missing or not retryable: {id}"
2195 )));
2196 }
2197 drop(connection);
2198 Ok(())
2199 }
2200
2201 pub fn recover_transfer_jobs(&self) -> Result<usize> {
2207 let connection = self
2208 .connection
2209 .lock()
2210 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2211 let now = current_timestamp().cast_signed();
2212 let recovered = connection
2213 .execute(
2214 "UPDATE transfer_jobs
2215 SET state = 'queued', updated_at = ?1
2216 WHERE state IN ('fetching', 'materializing')",
2217 params![now],
2218 )
2219 .map_err(|error| SyncwebError::operation("failed to recover transfer jobs", error))?;
2220 drop(connection);
2221 Ok(recovered)
2222 }
2223
2224 pub fn reserved_transfer_bytes(&self, root_id: &str) -> Result<u64> {
2230 let connection = self
2231 .connection
2232 .lock()
2233 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2234 let reserved: i64 = connection
2235 .query_row(
2236 "SELECT COALESCE(SUM(size), 0)
2237 FROM transfer_jobs
2238 WHERE root_id = ?1
2239 AND state IN ('queued', 'fetching', 'materializing', 'paused')",
2240 params![root_id],
2241 |row| row.get(0),
2242 )
2243 .map_err(|error| SyncwebError::operation("failed to query reserved transfer bytes", error))?;
2244 drop(connection);
2245 Ok(reserved.cast_unsigned())
2246 }
2247
2248 pub fn create_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
2258 let connection = self
2259 .connection
2260 .lock()
2261 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2262 let now = current_timestamp().cast_signed();
2263 connection
2264 .execute(
2265 "INSERT INTO sync_checkpoints(namespace_id, session_id, started_at, last_updated_at, status)
2266 VALUES (?1, ?2, ?3, ?4, 'running')",
2267 params![namespace_id, session_id, now, now],
2268 )
2269 .map_err(|error| SyncwebError::operation("failed to create sync checkpoint", error))?;
2270 drop(connection);
2271 Ok(())
2272 }
2273
2274 pub fn upsert_sync_entry(&self, p: &SyncEntryParams<'_>) -> Result<()> {
2280 let connection = self
2281 .connection
2282 .lock()
2283 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2284 let now = current_timestamp().cast_signed();
2285 connection
2286 .execute(
2287 "INSERT INTO sync_entry_progress(namespace_id, session_id, entry_key, hash, size, status, retries, error_message, updated_at)
2288 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2289 ON CONFLICT(namespace_id, session_id, entry_key) DO UPDATE SET
2290 status = excluded.status,
2291 retries = excluded.retries,
2292 error_message = excluded.error_message,
2293 updated_at = excluded.updated_at",
2294 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],
2295 )
2296 .map_err(|error| SyncwebError::operation("failed to upsert sync entry", error))?;
2297 drop(connection);
2298 Ok(())
2299 }
2300
2301 pub fn list_sync_entries(
2307 &self,
2308 namespace_id: &str,
2309 session_id: &str,
2310 status: &str,
2311 ) -> Result<Vec<crate::sync::checkpoint::EntryProgress>> {
2312 let connection = self
2313 .connection
2314 .lock()
2315 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2316 let mut stmt = connection
2317 .prepare(
2318 "SELECT entry_key, hash, size, status, retries, error_message
2319 FROM sync_entry_progress
2320 WHERE namespace_id = ?1 AND session_id = ?2 AND status = ?3",
2321 )
2322 .map_err(|error| SyncwebError::operation("failed to prepare sync entry query", error))?;
2323 let entries = stmt
2324 .query_map(params![namespace_id, session_id, status], |row| {
2325 let entry_key: Vec<u8> = row.get(0)?;
2326 let hash_bytes: Vec<u8> = row.get(1)?;
2327 let hash_str = String::from_utf8_lossy(&hash_bytes);
2328 let hash = hash_str.parse::<iroh_blobs::Hash>().unwrap_or(iroh_blobs::Hash::EMPTY);
2329 let size: i64 = row.get(2)?;
2330 let status_str: String = row.get(3)?;
2331 let retries: i64 = row.get(4)?;
2332 let error_message: Option<String> = row.get(5)?;
2333 Ok(crate::sync::checkpoint::EntryProgress {
2334 entry_key,
2335 hash,
2336 size: size.cast_unsigned(),
2337 status: status_str,
2338 retries: u32::try_from(retries).unwrap_or(0),
2339 error_message,
2340 })
2341 })
2342 .map_err(|error| SyncwebError::operation("failed to query sync entries", error))?
2343 .collect::<std::result::Result<Vec<_>, _>>()
2344 .map_err(|error| SyncwebError::operation("failed to read sync entry rows", error))?;
2345 drop(stmt);
2346 drop(connection);
2347 Ok(entries)
2348 }
2349
2350 pub fn get_checkpoint_progress(
2356 &self,
2357 namespace_id: &str,
2358 session_id: &str,
2359 ) -> Result<crate::sync::checkpoint::CheckpointProgress> {
2360 let connection = self
2361 .connection
2362 .lock()
2363 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2364 let (total, completed, failed, skipped, pending, bytes_transferred, bytes_total): (
2365 i64, i64, i64, i64, i64, i64, Option<i64>,
2366 ) = connection
2367 .query_row(
2368 "SELECT
2369 (SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2),
2370 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
2371 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'failed'), 0),
2372 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'skipped'), 0),
2373 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'pending'), 0),
2374 COALESCE((SELECT SUM(size) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
2375 (SELECT bytes_total FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2)",
2376 params![namespace_id, session_id],
2377 |row| Ok((
2378 row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?
2379 )),
2380 )
2381 .map_err(|error| SyncwebError::operation("failed to query checkpoint progress", error))?;
2382 drop(connection);
2383 let percentage = if total > 0 {
2384 let sum = completed.checked_add(skipped).unwrap_or(0);
2385 let pct_int = sum.checked_mul(100).and_then(|n| n.checked_div(total)).unwrap_or(0);
2386 f64::from(i32::try_from(pct_int).unwrap_or(0))
2387 } else {
2388 0.0
2389 };
2390 Ok(crate::sync::checkpoint::CheckpointProgress {
2391 total: usize::try_from(total).unwrap_or(0),
2392 completed: usize::try_from(completed).unwrap_or(0),
2393 failed: usize::try_from(failed).unwrap_or(0),
2394 skipped: usize::try_from(skipped).unwrap_or(0),
2395 pending: usize::try_from(pending).unwrap_or(0),
2396 bytes_transferred: bytes_transferred.cast_unsigned(),
2397 bytes_total: bytes_total.map(i64::cast_unsigned),
2398 percentage,
2399 })
2400 }
2401
2402 pub fn update_checkpoint_status(&self, namespace_id: &str, session_id: &str, status: &str) -> Result<()> {
2408 let connection = self
2409 .connection
2410 .lock()
2411 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2412 let now = current_timestamp().cast_signed();
2413 connection
2414 .execute(
2415 "UPDATE sync_checkpoints SET status = ?1, last_updated_at = ?2
2416 WHERE namespace_id = ?3 AND session_id = ?4",
2417 params![status, now, namespace_id, session_id],
2418 )
2419 .map_err(|error| SyncwebError::operation("failed to update checkpoint status", error))?;
2420 drop(connection);
2421 Ok(())
2422 }
2423
2424 pub fn find_unfinished_checkpoint(&self, namespace_id: &str) -> Result<Option<String>> {
2430 let connection = self
2431 .connection
2432 .lock()
2433 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2434 let result: Option<String> = connection
2435 .query_row(
2436 "SELECT session_id FROM sync_checkpoints
2437 WHERE namespace_id = ?1 AND status IN ('running', 'pending')
2438 ORDER BY started_at DESC LIMIT 1",
2439 params![namespace_id],
2440 |row| row.get(0),
2441 )
2442 .optional()
2443 .map_err(|error| SyncwebError::operation("failed to find unfinished checkpoint", error))?;
2444 drop(connection);
2445 Ok(result)
2446 }
2447
2448 pub fn delete_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
2454 let connection = self
2455 .connection
2456 .lock()
2457 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2458 connection
2459 .execute(
2460 "DELETE FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2",
2461 params![namespace_id, session_id],
2462 )
2463 .map_err(|error| SyncwebError::operation("failed to delete sync entry progress", error))?;
2464 connection
2465 .execute(
2466 "DELETE FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2",
2467 params![namespace_id, session_id],
2468 )
2469 .map_err(|error| SyncwebError::operation("failed to delete sync checkpoint", error))?;
2470 drop(connection);
2471 Ok(())
2472 }
2473
2474 pub fn clean_stale_checkpoints(&self, max_age_secs: i64) -> Result<usize> {
2480 let connection = self
2481 .connection
2482 .lock()
2483 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2484 let cutoff = current_timestamp().cast_signed().saturating_sub(max_age_secs);
2485 let deleted = connection
2486 .execute(
2487 "DELETE FROM sync_checkpoints WHERE last_updated_at < ?1 AND status NOT IN ('completed')",
2488 params![cutoff],
2489 )
2490 .map_err(|error| SyncwebError::operation("failed to clean stale checkpoints", error))?;
2491 drop(connection);
2492 Ok(deleted)
2493 }
2494
2495 pub fn vacuum(&self) -> Result<()> {
2505 let connection = self
2506 .connection
2507 .lock()
2508 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2509 connection
2510 .execute_batch("VACUUM")
2511 .map_err(|error| SyncwebError::operation("vacuum failed", error))?;
2512 drop(connection);
2513 Ok(())
2514 }
2515
2516 pub fn size_on_disk(&self) -> Result<u64> {
2522 let connection = self
2523 .connection
2524 .lock()
2525 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2526 let db_path: String = connection
2527 .query_row("PRAGMA database_list", [], |row| row.get(2))
2528 .map_err(|error| SyncwebError::operation("failed to query database path", error))?;
2529 drop(connection);
2530 std::fs::metadata(&db_path)
2531 .map(|meta| meta.len())
2532 .map_err(|error| SyncwebError::operation("failed to get database file size", error))
2533 }
2534
2535 pub fn freelist_count(&self) -> Result<i64> {
2541 let connection = self
2542 .connection
2543 .lock()
2544 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2545 let count: i64 = connection
2546 .pragma_query_value(None, "freelist_count", |row| row.get(0))
2547 .map_err(|error| SyncwebError::operation("failed to query freelist count", error))?;
2548 drop(connection);
2549 Ok(count)
2550 }
2551
2552 pub fn check_integrity(&self) -> Result<Vec<String>> {
2558 let connection = self
2559 .connection
2560 .lock()
2561 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2562 let mut stmt = connection
2563 .prepare("PRAGMA integrity_check")
2564 .map_err(|error| SyncwebError::operation("failed to prepare integrity check", error))?;
2565 let errors: Vec<String> = stmt
2566 .query_map([], |row| row.get::<_, String>(0))
2567 .map_err(|error| SyncwebError::operation("failed to run integrity check", error))?
2568 .filter_map(std::result::Result::ok)
2569 .filter(|s| s != "ok")
2570 .collect();
2571 drop(stmt);
2572 drop(connection);
2573 Ok(errors)
2574 }
2575}
2576
2577impl crate::storage::Vacuumable for NodeDatabase {
2578 fn vacuum(&self) -> Result<()> {
2579 self.vacuum()
2580 }
2581
2582 fn freelist_count(&self) -> Result<i64> {
2583 self.freelist_count()
2584 }
2585}
2586
2587#[cfg(test)]
2588mod tests {
2589 use super::*;
2590 use std::sync::Arc;
2591
2592 fn test_db() -> NodeDatabase {
2593 let conn = Connection::open_in_memory().unwrap();
2594 let db = NodeDatabase {
2595 connection: Arc::new(Mutex::new(conn)),
2596 };
2597 db.init_schema().unwrap();
2598 db
2599 }
2600
2601 fn make_public_key(seed: u8) -> PublicKey {
2602 iroh::SecretKey::from_bytes(&[seed; 32]).public()
2603 }
2604
2605 #[test]
2606 fn status_round_trips_kind_mode_and_sync_count() {
2607 let db = test_db();
2608 let mut folder = FolderStatusReport::new("ns-1", "/tmp/one", true, Some(1_700_000_000), 12, Vec::new());
2609 folder.kind = "folder".to_owned();
2610 folder.mode = "receiveonly".to_owned();
2611 folder.sync_count = 4;
2612
2613 let mut report = DaemonStatusReport::from_state(
2614 &DaemonState::new(
2615 42,
2616 "node-1".to_owned(),
2617 1_700_000_000,
2618 std::path::Path::new("/tmp/store"),
2619 DaemonStatus::Running,
2620 ),
2621 5,
2622 vec![folder.clone()],
2623 BandwidthSnapshot::default(),
2624 None,
2625 4,
2626 );
2627 report.pid = 42;
2628 db.save_status(&report).unwrap();
2629
2630 let loaded = db.load_status().unwrap().expect("status should be persisted");
2631 let saved = loaded.folders.first().expect("the folder row should be present");
2632 assert_eq!(saved.namespace, "ns-1");
2633 assert_eq!(saved.kind, "folder", "kind must survive a restart");
2634 assert_eq!(saved.mode, "receiveonly", "mode must survive a restart");
2635 assert_eq!(saved.sync_count, 4, "sync_count must survive a restart");
2636 assert_eq!(saved.entries_synced, 12);
2637 assert_eq!(saved, &folder);
2638 }
2639
2640 #[test]
2641 fn status_without_a_stored_row_keeps_the_default_kind() {
2642 let db = test_db();
2643 let folder = FolderStatusReport::new("ns-1", "/tmp/one", false, None, 0, Vec::new());
2644 let report = DaemonStatusReport::from_state(
2645 &DaemonState::new(
2646 42,
2647 "node-1".to_owned(),
2648 1_700_000_000,
2649 std::path::Path::new("/tmp/store"),
2650 DaemonStatus::Running,
2651 ),
2652 5,
2653 vec![folder],
2654 BandwidthSnapshot::default(),
2655 None,
2656 4,
2657 );
2658 db.save_status(&report).unwrap();
2659
2660 let loaded = db.load_status().unwrap().expect("status should be persisted");
2661 let saved = loaded.folders.first().expect("the folder row should be present");
2662 assert_eq!(saved.kind, "folder", "an unset kind should read back as the default");
2663 assert!(
2664 saved.mode.is_empty(),
2665 "an unknown mode stays empty rather than being invented"
2666 );
2667 }
2668
2669 #[test]
2672 fn an_older_status_table_gains_the_new_columns() {
2673 let conn = Connection::open_in_memory().unwrap();
2674 conn.execute_batch(
2675 "CREATE TABLE folder_status_reports (
2676 namespace_id TEXT PRIMARY KEY,
2677 path TEXT NOT NULL,
2678 session_active INTEGER NOT NULL,
2679 last_sync_at INTEGER,
2680 entries_synced INTEGER NOT NULL,
2681 errors_json TEXT NOT NULL
2682 );
2683 INSERT INTO folder_status_reports
2684 VALUES ('old-ns', '/tmp/old', 0, NULL, 7, '[]');
2685 CREATE TABLE daemon_status (
2686 id INTEGER PRIMARY KEY CHECK(id = 1),
2687 pid INTEGER NOT NULL,
2688 node_id TEXT NOT NULL,
2689 started_at INTEGER NOT NULL,
2690 uptime_seconds INTEGER NOT NULL,
2691 upload_total INTEGER NOT NULL DEFAULT 0,
2692 download_total INTEGER NOT NULL DEFAULT 0,
2693 upload_rate INTEGER NOT NULL DEFAULT 0,
2694 download_rate INTEGER NOT NULL DEFAULT 0,
2695 has_schedule INTEGER NOT NULL DEFAULT 0,
2696 in_active_window INTEGER NOT NULL DEFAULT 0,
2697 next_window_start INTEGER,
2698 rayon_threads INTEGER NOT NULL,
2699 updated_at INTEGER NOT NULL
2700 );
2701 INSERT INTO daemon_status
2702 VALUES (1, 11, 'node-old', 1700000000, 3, 0, 0, 0, 0, 0, 0, NULL, 2, 1700000000);",
2703 )
2704 .unwrap();
2705 let db = NodeDatabase {
2706 connection: Arc::new(Mutex::new(conn)),
2707 };
2708 db.init_schema().unwrap();
2709
2710 let loaded = db.load_status().unwrap().expect("the old row should still load");
2711 let folder = loaded.folders.first().expect("the old row should still be there");
2712 assert_eq!(folder.namespace, "old-ns");
2713 assert_eq!(folder.entries_synced, 7, "existing columns must be preserved");
2714 assert_eq!(folder.kind, "folder");
2715 assert!(folder.mode.is_empty());
2716 assert_eq!(folder.sync_count, 0);
2717 }
2718
2719 #[test]
2720 fn test_is_peer_network_member_true() {
2721 let db = test_db();
2722 let member_key = make_public_key(1);
2723 let owner = make_public_key(2);
2724
2725 let mut network = Network::new("test", owner, crate::net::network::NetworkOptions::default());
2726 network.members.insert(member_key);
2727 db.create_network(&network).unwrap();
2728
2729 assert!(db.is_peer_network_member(&member_key.to_string()).unwrap());
2730 }
2731
2732 #[test]
2733 fn test_is_peer_network_member_false() {
2734 let db = test_db();
2735 assert!(!db.is_peer_network_member("unknown-peer").unwrap());
2736 }
2737
2738 #[test]
2739 fn test_transfer_roots_and_jobs_survive_lifecycle_updates() {
2740 let db = test_db();
2741 db.upsert_storage_root(&StorageRootRecord {
2742 id: "root".to_owned(),
2743 path: PathBuf::from("/tmp/syncweb-root"),
2744 min_free: 10,
2745 enabled: true,
2746 })
2747 .unwrap();
2748 let hash = [7_u8; 32];
2749 let entry_key = b"dir/file.bin";
2750 let job_id = db
2751 .enqueue_transfer_job(&NewTransferJob {
2752 namespace_id: "namespace",
2753 entry_key,
2754 hash: &hash,
2755 size: 25,
2756 root_id: Some("root"),
2757 destination: Some(Path::new("/tmp/syncweb-root/namespace/dir/file.bin")),
2758 })
2759 .unwrap();
2760
2761 assert_eq!(db.reserved_transfer_bytes("root").unwrap(), 25);
2762 let jobs = db.list_transfer_jobs(Some("namespace"), Some("queued")).unwrap();
2763 assert_eq!(jobs.len(), 1);
2764 let job = jobs.first().expect("queued transfer job should be listed");
2765 assert_eq!(job.id, job_id);
2766 assert_eq!(job.hash, hash);
2767 assert_eq!(
2768 job.destination.as_deref(),
2769 Some(Path::new("/tmp/syncweb-root/namespace/dir/file.bin"))
2770 );
2771
2772 db.update_transfer_job_progress(&job_id, 12, 2, Some(4), 1).unwrap();
2773 db.update_transfer_job_state(&job_id, "materializing", None).unwrap();
2774 assert_eq!(db.recover_transfer_jobs().unwrap(), 1);
2775 let recovered = db.list_transfer_jobs(None, Some("queued")).unwrap();
2776 let recovered_job = recovered.first().expect("recovered transfer job should be listed");
2777 assert_eq!(recovered_job.bytes_transferred, 12);
2778 assert_eq!(recovered_job.retries, 1);
2779 }
2780
2781 #[test]
2782 fn test_assign_transfer_job_requires_queued_job() {
2783 let db = test_db();
2784 db.upsert_storage_root(&StorageRootRecord {
2785 id: "root".to_owned(),
2786 path: PathBuf::from("/tmp/syncweb-root"),
2787 min_free: 0,
2788 enabled: true,
2789 })
2790 .unwrap();
2791 let hash = [3_u8; 32];
2792 let job_id = db
2793 .enqueue_transfer_job(&NewTransferJob {
2794 namespace_id: "namespace",
2795 entry_key: b"file",
2796 hash: &hash,
2797 size: 1,
2798 root_id: None,
2799 destination: None,
2800 })
2801 .unwrap();
2802 db.assign_transfer_job(&job_id, "root", Path::new("/tmp/file")).unwrap();
2803 let jobs = db.list_transfer_jobs(None, None).unwrap();
2804 let job = jobs.first().expect("assigned transfer job should be listed");
2805 assert_eq!(job.root_id.as_deref(), Some("root"));
2806 }
2807}