1use std::{
2 collections::{BTreeMap, HashMap, HashSet},
3 path::{Path, PathBuf},
4 str::FromStr,
5 sync::{Arc, Mutex},
6};
7
8use iroh::PublicKey;
9use iroh_blobs::Hash;
10use iroh_docs::NamespaceId;
11use rusqlite::{Connection, OptionalExtension, params};
12use serde::{Deserialize, Serialize};
13use tracing;
14use uuid::Uuid;
15
16use crate::{
17 Result, SyncwebError,
18 daemon::{
19 BandwidthSnapshot, DaemonState, DaemonStatus, DaemonStatusReport, FolderStatusReport, ScheduleStatus,
20 current_timestamp,
21 },
22 filter::{FilterAction, FilterEngine, FilterEntry, FilterRule},
23 folder::{CollectionState, InstalledCollection},
24 net::network::{Network, NetworkId, network_topic, parse_public_key},
25 storage::config::Config as AppConfig,
26};
27
28#[derive(Debug)]
30#[non_exhaustive]
31pub struct SyncEntryParams<'a> {
32 pub namespace_id: &'a str,
33 pub session_id: &'a str,
34 pub entry_key: &'a [u8],
35 pub hash: &'a [u8],
36 pub size: u64,
37 pub status: &'a str,
38 pub retries: u32,
39 pub error_message: Option<&'a str>,
40}
41
42#[derive(Clone, Debug, Deserialize, Serialize)]
43#[non_exhaustive]
44pub struct StorageRootRecord {
45 pub id: String,
46 pub path: PathBuf,
47 pub min_free: u64,
48 pub enabled: bool,
49}
50
51impl StorageRootRecord {
52 #[must_use]
54 pub fn new(id: impl Into<String>, path: impl Into<PathBuf>, min_free: u64, enabled: bool) -> Self {
55 Self {
56 id: id.into(),
57 path: path.into(),
58 min_free,
59 enabled,
60 }
61 }
62}
63
64#[derive(Clone, Debug, Deserialize, Serialize)]
65#[non_exhaustive]
66pub struct TransferJobRecord {
67 pub id: String,
68 pub namespace_id: String,
69 pub entry_key: Vec<u8>,
70 pub hash: [u8; 32],
71 pub size: u64,
72 pub root_id: Option<String>,
73 pub destination: Option<PathBuf>,
74 pub state: String,
75 pub bytes_transferred: u64,
76 pub peer_count: u64,
77 pub eta_seconds: Option<u64>,
78 pub retries: u32,
79 pub error_message: Option<String>,
80 pub created_at: u64,
81 pub updated_at: u64,
82}
83
84#[derive(Debug)]
85#[non_exhaustive]
86pub struct NewTransferJob<'a> {
87 pub namespace_id: &'a str,
88 pub entry_key: &'a [u8],
89 pub hash: &'a [u8; 32],
90 pub size: u64,
91 pub root_id: Option<&'a str>,
92 pub destination: Option<&'a Path>,
93}
94
95impl<'a> NewTransferJob<'a> {
96 #[must_use]
98 pub const fn new(
99 namespace_id: &'a str,
100 entry_key: &'a [u8],
101 hash: &'a [u8; 32],
102 size: u64,
103 root_id: Option<&'a str>,
104 destination: Option<&'a Path>,
105 ) -> Self {
106 Self {
107 namespace_id,
108 entry_key,
109 hash,
110 size,
111 root_id,
112 destination,
113 }
114 }
115}
116
117fn transfer_job_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TransferJobRecord> {
118 let hash_bytes: Vec<u8> = row.get(3)?;
119 let hash = hash_bytes.try_into().map_err(|bytes: Vec<u8>| {
120 rusqlite::Error::FromSqlConversionFailure(
121 3,
122 rusqlite::types::Type::Blob,
123 format!("expected 32-byte hash, got {} bytes", bytes.len()).into(),
124 )
125 })?;
126 let size: i64 = row.get(4)?;
127 let bytes_transferred: i64 = row.get(8)?;
128 let peer_count: i64 = row.get(9)?;
129 let eta_seconds: Option<i64> = row.get(10)?;
130 let retries: i64 = row.get(11)?;
131 let created_at: i64 = row.get(13)?;
132 let updated_at: i64 = row.get(14)?;
133 let retry_count = u32::try_from(retries).map_err(|error| {
134 rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Integer, Box::new(error))
135 })?;
136 Ok(TransferJobRecord {
137 id: row.get(0)?,
138 namespace_id: row.get(1)?,
139 entry_key: row.get(2)?,
140 hash,
141 size: size.cast_unsigned(),
142 root_id: row.get(5)?,
143 destination: row.get::<_, Option<String>>(6)?.map(PathBuf::from),
144 state: row.get(7)?,
145 bytes_transferred: bytes_transferred.cast_unsigned(),
146 peer_count: peer_count.cast_unsigned(),
147 eta_seconds: eta_seconds.map(i64::cast_unsigned),
148 retries: retry_count,
149 error_message: row.get(12)?,
150 created_at: created_at.cast_unsigned(),
151 updated_at: updated_at.cast_unsigned(),
152 })
153}
154
155#[derive(Clone, Debug)]
156pub struct NodeDatabase {
157 connection: Arc<Mutex<Connection>>,
158}
159
160impl NodeDatabase {
161 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
167 let db_path = path.as_ref();
168 if let Some(parent) = db_path.parent() {
169 std::fs::create_dir_all(parent)
170 .map_err(|error| SyncwebError::operation("failed to create node db directory", error))?;
171 }
172 let connection = Connection::open(db_path)
173 .map_err(|error| SyncwebError::operation("failed to open node database", error))?;
174 let db = Self {
175 connection: Arc::new(Mutex::new(connection)),
176 };
177 db.init_schema()?;
178 Ok(db)
179 }
180
181 const CREATE_SCHEMA_SQL: &'static str =
182 "CREATE TABLE IF NOT EXISTS schema_version (
183 key TEXT PRIMARY KEY,
184 value TEXT NOT NULL
185 );
186 CREATE TABLE IF NOT EXISTS daemon_lifecycle (
187 id INTEGER PRIMARY KEY CHECK(id = 1),
188 pid INTEGER NOT NULL,
189 node_id TEXT NOT NULL,
190 started_at INTEGER NOT NULL,
191 status TEXT NOT NULL CHECK(status IN ('starting','running','stopping','stopped')),
192 data_dir TEXT NOT NULL,
193 updated_at INTEGER NOT NULL
194 );
195 CREATE TABLE IF NOT EXISTS daemon_status (
196 id INTEGER PRIMARY KEY CHECK(id = 1),
197 pid INTEGER NOT NULL,
198 node_id TEXT NOT NULL,
199 started_at INTEGER NOT NULL,
200 uptime_seconds INTEGER NOT NULL,
201 upload_total INTEGER NOT NULL DEFAULT 0,
202 download_total INTEGER NOT NULL DEFAULT 0,
203 upload_rate INTEGER NOT NULL DEFAULT 0,
204 download_rate INTEGER NOT NULL DEFAULT 0,
205 has_schedule INTEGER NOT NULL DEFAULT 0,
206 in_active_window INTEGER NOT NULL DEFAULT 0,
207 next_window_start INTEGER,
208 rayon_threads INTEGER NOT NULL,
209 updated_at INTEGER NOT NULL
210 );
211 CREATE TABLE IF NOT EXISTS folder_status_reports (
212 namespace_id TEXT NOT NULL,
213 path TEXT NOT NULL,
214 session_active INTEGER NOT NULL DEFAULT 0,
215 last_sync_at INTEGER,
216 entries_synced INTEGER NOT NULL DEFAULT 0,
217 errors_json TEXT NOT NULL DEFAULT '[]',
218 updated_at INTEGER NOT NULL,
219 PRIMARY KEY(namespace_id)
220 );
221 CREATE TABLE IF NOT EXISTS networks (
222 id TEXT PRIMARY KEY,
223 name TEXT NOT NULL UNIQUE,
224 label TEXT NOT NULL DEFAULT '',
225 owner TEXT NOT NULL,
226 shared_secret BLOB,
227 created_at INTEGER NOT NULL
228 );
229 CREATE TABLE IF NOT EXISTS network_members (
230 network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
231 member TEXT NOT NULL,
232 PRIMARY KEY(network_id, member)
233 );
234 CREATE TABLE IF NOT EXISTS network_folders (
235 network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
236 namespace_id TEXT NOT NULL,
237 PRIMARY KEY(network_id, namespace_id)
238 );
239 CREATE TABLE IF NOT EXISTS installed_collections (
240 collection_id TEXT PRIMARY KEY,
241 manifest_hash TEXT NOT NULL,
242 current_version TEXT NOT NULL,
243 installed_at INTEGER NOT NULL
244 );
245 CREATE TABLE IF NOT EXISTS collection_versions (
246 collection_id TEXT NOT NULL REFERENCES installed_collections(collection_id) ON DELETE CASCADE,
247 version TEXT NOT NULL,
248 install_path TEXT NOT NULL,
249 PRIMARY KEY(collection_id, version)
250 );
251 CREATE TABLE IF NOT EXISTS app_config (
252 key TEXT PRIMARY KEY,
253 value TEXT NOT NULL,
254 updated_at INTEGER NOT NULL
255 );
256 CREATE TABLE IF NOT EXISTS filter_rules (
257 id INTEGER PRIMARY KEY AUTOINCREMENT,
258 namespace_id TEXT,
259 rule_type TEXT NOT NULL,
260 pattern TEXT NOT NULL,
261 priority INTEGER NOT NULL DEFAULT 0,
262 updated_at INTEGER NOT NULL
263 );
264 CREATE TABLE IF NOT EXISTS public_subscriptions (
265 hash TEXT PRIMARY KEY,
266 size INTEGER NOT NULL,
267 subscribed_at INTEGER NOT NULL
268 );
269 CREATE TABLE IF NOT EXISTS workspace_manifests (
270 source_path TEXT NOT NULL,
271 manifest_bytes BLOB NOT NULL,
272 collection_id TEXT NOT NULL,
273 updated_at INTEGER NOT NULL,
274 PRIMARY KEY(source_path)
275 );
276 CREATE TABLE IF NOT EXISTS blob_folders (
277 content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
278 namespace_id TEXT NOT NULL,
279 entry_key BLOB NOT NULL,
280 added_at INTEGER NOT NULL,
281 PRIMARY KEY(content_hash, namespace_id, entry_key)
282 );
283 CREATE INDEX IF NOT EXISTS idx_blob_folders_hash ON blob_folders(content_hash);
284 CREATE TABLE IF NOT EXISTS sync_checkpoints (
285 namespace_id TEXT NOT NULL,
286 session_id TEXT NOT NULL,
287 total_entries INTEGER NOT NULL DEFAULT 0,
288 processed_entries INTEGER NOT NULL DEFAULT 0,
289 failed_entries INTEGER NOT NULL DEFAULT 0,
290 bytes_total INTEGER,
291 bytes_transferred INTEGER NOT NULL DEFAULT 0,
292 started_at INTEGER NOT NULL,
293 last_updated_at INTEGER NOT NULL,
294 status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','completed','failed','cancelled')),
295 PRIMARY KEY(namespace_id, session_id)
296 );
297 CREATE TABLE IF NOT EXISTS sync_entry_progress (
298 namespace_id TEXT NOT NULL,
299 session_id TEXT NOT NULL,
300 entry_key BLOB NOT NULL,
301 hash BLOB NOT NULL,
302 size INTEGER NOT NULL,
303 status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','downloading','completed','failed','skipped')),
304 retries INTEGER NOT NULL DEFAULT 0,
305 error_message TEXT,
306 updated_at INTEGER NOT NULL,
307 PRIMARY KEY(namespace_id, session_id, entry_key),
308 FOREIGN KEY(namespace_id, session_id) REFERENCES sync_checkpoints(namespace_id, session_id) ON DELETE CASCADE
309 );
310 CREATE TABLE IF NOT EXISTS storage_roots (
311 id TEXT PRIMARY KEY,
312 path TEXT NOT NULL UNIQUE,
313 min_free INTEGER NOT NULL DEFAULT 0 CHECK(min_free >= 0),
314 enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
315 created_at INTEGER NOT NULL,
316 updated_at INTEGER NOT NULL
317 );
318 CREATE TABLE IF NOT EXISTS transfer_jobs (
319 id TEXT PRIMARY KEY,
320 namespace_id TEXT NOT NULL,
321 entry_key BLOB NOT NULL,
322 hash BLOB NOT NULL CHECK(length(hash) = 32),
323 size INTEGER NOT NULL CHECK(size >= 0),
324 root_id TEXT,
325 destination TEXT,
326 state TEXT NOT NULL DEFAULT 'queued'
327 CHECK(state IN ('queued','fetching','materializing','paused','completed','failed','cancelled')),
328 bytes_transferred INTEGER NOT NULL DEFAULT 0 CHECK(bytes_transferred >= 0),
329 peer_count INTEGER NOT NULL DEFAULT 0 CHECK(peer_count >= 0),
330 eta_seconds INTEGER,
331 retries INTEGER NOT NULL DEFAULT 0 CHECK(retries >= 0),
332 error_message TEXT,
333 created_at INTEGER NOT NULL,
334 updated_at INTEGER NOT NULL,
335 UNIQUE(namespace_id, entry_key, root_id),
336 FOREIGN KEY(root_id) REFERENCES storage_roots(id) ON DELETE SET NULL
337 );";
338
339 const fn create_schema_sql() -> &'static str {
341 Self::CREATE_SCHEMA_SQL
342 }
343
344 pub fn init_schema(&self) -> Result<()> {
350 let connection = self
351 .connection
352 .lock()
353 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
354 connection
355 .busy_timeout(std::time::Duration::from_secs(5))
356 .map_err(|error| SyncwebError::operation("failed to configure node database", error))?;
357 connection
358 .pragma_update(None, "journal_mode", "WAL")
359 .map_err(|error| SyncwebError::operation("failed to set WAL mode", error))?;
360 connection
361 .pragma_update(None, "foreign_keys", "ON")
362 .map_err(|error| SyncwebError::operation("failed to enable foreign keys", error))?;
363 connection
364 .execute_batch(Self::create_schema_sql())
365 .map_err(|error| SyncwebError::operation("failed to initialize node database schema", error))?;
366 if let Err(error) = connection.execute_batch("ALTER TABLE networks ADD COLUMN doc_ticket TEXT") {
367 tracing::debug!(%error, "migration: add doc_ticket (may already exist)");
368 }
369 if let Err(error) = connection.execute_batch(
370 "CREATE INDEX IF NOT EXISTS idx_transfer_jobs_namespace ON transfer_jobs(namespace_id);
371 CREATE INDEX IF NOT EXISTS idx_transfer_jobs_state ON transfer_jobs(state);
372 CREATE INDEX IF NOT EXISTS idx_transfer_jobs_root ON transfer_jobs(root_id);",
373 ) {
374 tracing::debug!(%error, "migration: add transfer job indexes (may already exist)");
375 }
376
377 connection
378 .execute(
379 "INSERT INTO schema_version(key, value) VALUES ('version', '1')
380 ON CONFLICT(key) DO NOTHING",
381 [],
382 )
383 .map_err(|error| SyncwebError::operation("failed to seed schema version", error))?;
384 drop(connection);
385 Ok(())
386 }
387
388 pub fn save_lifecycle(&self, state: &DaemonState) -> Result<()> {
394 let connection = self
395 .connection
396 .lock()
397 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
398 let status = match state.status {
399 DaemonStatus::Starting => "starting",
400 DaemonStatus::Running => "running",
401 DaemonStatus::Stopping => "stopping",
402 DaemonStatus::Stopped => "stopped",
403 };
404 let now = current_timestamp();
405 connection
406 .execute(
407 "INSERT INTO daemon_lifecycle(id, pid, node_id, started_at, status, data_dir, updated_at)
408 VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)
409 ON CONFLICT(id) DO UPDATE SET
410 pid = excluded.pid,
411 node_id = excluded.node_id,
412 started_at = excluded.started_at,
413 status = excluded.status,
414 data_dir = excluded.data_dir,
415 updated_at = excluded.updated_at",
416 params![
417 i64::from(state.pid),
418 state.node_id,
419 state.started_at.cast_signed(),
420 status,
421 state.data_dir.to_string_lossy().as_ref(),
422 now.cast_signed(),
423 ],
424 )
425 .map_err(|error| SyncwebError::operation("failed to save daemon lifecycle", error))?;
426 drop(connection);
427 Ok(())
428 }
429
430 pub fn load_lifecycle(&self) -> Result<Option<DaemonState>> {
436 let connection = self
437 .connection
438 .lock()
439 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
440 let result = connection
441 .query_row(
442 "SELECT pid, node_id, started_at, status, data_dir FROM daemon_lifecycle WHERE id = 1",
443 [],
444 |row| {
445 let pid: i64 = row.get(0)?;
446 let node_id: String = row.get(1)?;
447 let started_at: i64 = row.get(2)?;
448 let status_str: String = row.get(3)?;
449 let data_dir: String = row.get(4)?;
450 let status = match status_str.as_str() {
451 "starting" => DaemonStatus::Starting,
452 "running" => DaemonStatus::Running,
453 "stopping" => DaemonStatus::Stopping,
454 "stopped" => DaemonStatus::Stopped,
455 _ => {
456 return Err(rusqlite::Error::InvalidColumnType(
457 3,
458 "status".to_string(),
459 rusqlite::types::Type::Text,
460 ));
461 }
462 };
463 Ok(DaemonState::new(
464 u32::try_from(pid).unwrap_or_default(),
465 node_id,
466 started_at.cast_unsigned(),
467 data_dir,
468 status,
469 ))
470 },
471 )
472 .optional()
473 .map_err(|error| SyncwebError::operation("failed to load daemon lifecycle", error))?;
474 drop(connection);
475 Ok(result)
476 }
477
478 pub fn remove_lifecycle(&self) -> Result<()> {
484 let connection = self
485 .connection
486 .lock()
487 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
488 connection
489 .execute("DELETE FROM daemon_lifecycle WHERE id = 1", [])
490 .map_err(|error| SyncwebError::operation("failed to remove daemon lifecycle", error))?;
491 drop(connection);
492 Ok(())
493 }
494
495 pub fn save_status(&self, report: &DaemonStatusReport) -> Result<()> {
501 let connection = self
502 .connection
503 .lock()
504 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
505 let now = current_timestamp().cast_signed();
506 let has_schedule = report.schedule.is_some();
507 connection
508 .execute(
509 "INSERT INTO daemon_status(id, pid, node_id, started_at, uptime_seconds,
510 upload_total, download_total, upload_rate, download_rate,
511 has_schedule, in_active_window, next_window_start, rayon_threads, updated_at)
512 VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
513 ON CONFLICT(id) DO UPDATE SET
514 pid = excluded.pid,
515 node_id = excluded.node_id,
516 started_at = excluded.started_at,
517 uptime_seconds = excluded.uptime_seconds,
518 upload_total = excluded.upload_total,
519 download_total = excluded.download_total,
520 upload_rate = excluded.upload_rate,
521 download_rate = excluded.download_rate,
522 has_schedule = excluded.has_schedule,
523 in_active_window = excluded.in_active_window,
524 next_window_start = excluded.next_window_start,
525 rayon_threads = excluded.rayon_threads,
526 updated_at = excluded.updated_at",
527 params![
528 i64::from(report.pid),
529 report.node_id,
530 report.started_at.cast_signed(),
531 report.uptime_seconds.cast_signed(),
532 report.bandwidth.upload_total.cast_signed(),
533 report.bandwidth.download_total.cast_signed(),
534 report.bandwidth.upload_rate.cast_signed(),
535 report.bandwidth.download_rate.cast_signed(),
536 i64::from(has_schedule),
537 report.schedule.map_or(0, |s| i64::from(s.in_active_window)),
538 report.schedule.and_then(|s| s.next_window_start.map(u64::cast_signed)),
539 i64::try_from(report.rayon_threads).unwrap_or_default(),
540 now,
541 ],
542 )
543 .map_err(|error| SyncwebError::operation("failed to save daemon status", error))?;
544
545 for folder in &report.folders {
546 let errors_json = serde_json::to_string(&folder.errors).unwrap_or_else(|_| "[]".to_string());
547 connection
548 .execute(
549 "INSERT INTO folder_status_reports(namespace_id, path, session_active, last_sync_at,
550 entries_synced, errors_json, updated_at)
551 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
552 ON CONFLICT(namespace_id) DO UPDATE SET
553 path = excluded.path,
554 session_active = excluded.session_active,
555 last_sync_at = excluded.last_sync_at,
556 entries_synced = excluded.entries_synced,
557 errors_json = excluded.errors_json,
558 updated_at = excluded.updated_at",
559 params![
560 folder.namespace,
561 folder.path.to_string_lossy().as_ref(),
562 i64::from(folder.session_active),
563 folder.last_sync_at.map(u64::cast_signed),
564 folder.entries_synced.cast_signed(),
565 errors_json,
566 now,
567 ],
568 )
569 .map_err(|error| SyncwebError::operation("failed to save folder status", error))?;
570 }
571 drop(connection);
572 Ok(())
573 }
574
575 pub fn load_status(&self) -> Result<Option<DaemonStatusReport>> {
581 let connection = self
582 .connection
583 .lock()
584 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
585 let result: Option<DaemonStatusReport> = connection
586 .query_row(
587 "SELECT pid, node_id, started_at, uptime_seconds,
588 upload_total, download_total, upload_rate, download_rate,
589 has_schedule, in_active_window, next_window_start, rayon_threads
590 FROM daemon_status WHERE id = 1",
591 [],
592 |row| {
593 let pid: i64 = row.get(0)?;
594 let node_id: String = row.get(1)?;
595 let started_at: i64 = row.get(2)?;
596 let uptime_seconds: i64 = row.get(3)?;
597 let upload_total: i64 = row.get(4)?;
598 let download_total: i64 = row.get(5)?;
599 let upload_rate: i64 = row.get(6)?;
600 let download_rate: i64 = row.get(7)?;
601 let has_schedule: i64 = row.get(8)?;
602 let in_active_window: i64 = row.get(9)?;
603 let next_window_start: Option<i64> = row.get(10)?;
604 let rayon_threads: i64 = row.get(11)?;
605
606 let schedule = (has_schedule != 0).then(|| ScheduleStatus {
607 in_active_window: in_active_window != 0,
608 next_window_start: next_window_start.map(i64::cast_unsigned),
609 });
610
611 Ok(DaemonStatusReport {
612 pid: u32::try_from(pid).unwrap_or_default(),
613 node_id,
614 started_at: started_at.cast_unsigned(),
615 uptime_seconds: uptime_seconds.cast_unsigned(),
616 folders: Vec::new(),
617 bandwidth: BandwidthSnapshot {
618 upload_total: upload_total.cast_unsigned(),
619 download_total: download_total.cast_unsigned(),
620 upload_rate: upload_rate.cast_unsigned(),
621 download_rate: download_rate.cast_unsigned(),
622 },
623 schedule,
624 rayon_threads: usize::try_from(rayon_threads).unwrap_or_default(),
625 })
626 },
627 )
628 .optional()
629 .map_err(|error| SyncwebError::operation("failed to load daemon status", error))?;
630
631 if let Some(mut report) = result {
632 let mut folder_stmt = connection
633 .prepare(
634 "SELECT namespace_id, path, session_active, last_sync_at, entries_synced, errors_json
635 FROM folder_status_reports",
636 )
637 .map_err(|error| SyncwebError::operation("failed to prepare folder status query", error))?;
638 let folders: Vec<FolderStatusReport> = folder_stmt
639 .query_map([], |row| {
640 let namespace: String = row.get(0)?;
641 let path: String = row.get(1)?;
642 let session_active: i64 = row.get(2)?;
643 let last_sync_at: Option<i64> = row.get(3)?;
644 let entries_synced: i64 = row.get(4)?;
645 let errors_json: String = row.get(5)?;
646 let errors: Vec<String> = serde_json::from_str(&errors_json).unwrap_or_default();
647 Ok(FolderStatusReport::new(
648 namespace,
649 path,
650 session_active != 0,
651 last_sync_at.map(i64::cast_unsigned),
652 entries_synced.cast_unsigned(),
653 errors,
654 ))
655 })
656 .map_err(|error| SyncwebError::operation("failed to query folder statuses", error))?
657 .collect::<std::result::Result<Vec<_>, _>>()
658 .map_err(|error| SyncwebError::operation("failed to read folder status rows", error))?;
659 report.folders = folders;
660 drop(folder_stmt);
661 drop(connection);
662 Ok(Some(report))
663 } else {
664 drop(connection);
665 Ok(None)
666 }
667 }
668
669 pub fn remove_status(&self) -> Result<()> {
675 let connection = self
676 .connection
677 .lock()
678 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
679 connection
680 .execute("DELETE FROM daemon_status WHERE id = 1", [])
681 .map_err(|error| SyncwebError::operation("failed to remove daemon status", error))?;
682 connection
683 .execute("DELETE FROM folder_status_reports", [])
684 .map_err(|error| SyncwebError::operation("failed to remove folder status reports", error))?;
685 drop(connection);
686 Ok(())
687 }
688
689 pub fn create_network(&self, network: &Network) -> Result<()> {
695 let connection = self
696 .connection
697 .lock()
698 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
699 let now = current_timestamp().cast_signed();
700 connection.execute(
701 "INSERT INTO networks(id, name, label, owner, shared_secret, created_at, doc_ticket) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
702 params![
703 network.id.to_string(),
704 network.name,
705 network.label,
706 network.owner.to_string(),
707 network.shared_secret.as_ref().map(|s| s.to_vec()),
708 now,
709 network.doc_ticket,
710 ],
711 ).map_err(|error| SyncwebError::operation("failed to create network", error))?;
712 for member in &network.members {
713 connection
714 .execute(
715 "INSERT INTO network_members(network_id, member) VALUES (?1, ?2)",
716 params![network.id.to_string(), member.to_string()],
717 )
718 .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
719 }
720 for folder in &network.folders {
721 connection
722 .execute(
723 "INSERT INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
724 params![network.id.to_string(), folder.to_string()],
725 )
726 .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
727 }
728 drop(connection);
729 Ok(())
730 }
731
732 pub fn delete_network(&self, id: NetworkId) -> Result<()> {
738 let connection = self
739 .connection
740 .lock()
741 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
742 let affected = connection
743 .execute("DELETE FROM networks WHERE id = ?1", params![id.to_string()])
744 .map_err(|error| SyncwebError::operation("failed to delete network", error))?;
745 if affected == 0 {
746 drop(connection);
747 return Err(SyncwebError::FolderNotFound(format!("network {id}")));
748 }
749 drop(connection);
750 Ok(())
751 }
752
753 pub fn add_member(&self, network_id: NetworkId, member: PublicKey) -> Result<()> {
759 let connection = self
760 .connection
761 .lock()
762 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
763 connection
764 .execute(
765 "INSERT OR IGNORE INTO network_members(network_id, member) VALUES (?1, ?2)",
766 params![network_id.to_string(), member.to_string()],
767 )
768 .map_err(|error| SyncwebError::operation("failed to add network member", error))?;
769 drop(connection);
770 Ok(())
771 }
772
773 pub fn remove_member(&self, network_id: NetworkId, member: &PublicKey) -> Result<bool> {
779 let connection = self
780 .connection
781 .lock()
782 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
783 let affected = connection
784 .execute(
785 "DELETE FROM network_members WHERE network_id = ?1 AND member = ?2",
786 params![network_id.to_string(), member.to_string()],
787 )
788 .map_err(|error| SyncwebError::operation("failed to remove network member", error))?;
789 drop(connection);
790 Ok(affected > 0)
791 }
792
793 pub fn add_folder_to_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
799 let connection = self
800 .connection
801 .lock()
802 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
803 connection
804 .execute(
805 "INSERT OR IGNORE INTO network_folders(network_id, namespace_id) VALUES (?1, ?2)",
806 params![network_id.to_string(), namespace_id.to_string()],
807 )
808 .map_err(|error| SyncwebError::operation("failed to add network folder", error))?;
809 drop(connection);
810 Ok(())
811 }
812
813 pub fn remove_folder_from_network(&self, network_id: NetworkId, namespace_id: NamespaceId) -> Result<()> {
819 let connection = self
820 .connection
821 .lock()
822 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
823 connection
824 .execute(
825 "DELETE FROM network_folders WHERE network_id = ?1 AND namespace_id = ?2",
826 params![network_id.to_string(), namespace_id.to_string()],
827 )
828 .map_err(|error| SyncwebError::operation("failed to remove network folder", error))?;
829 drop(connection);
830 Ok(())
831 }
832
833 pub fn list_networks(&self) -> Result<Vec<Network>> {
839 let connection = self
840 .connection
841 .lock()
842 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
843 let mut stmt = connection
844 .prepare("SELECT id, name, label, owner, shared_secret, doc_ticket FROM networks ORDER BY name")
845 .map_err(|error| SyncwebError::operation("failed to prepare network query", error))?;
846 let networks = stmt
847 .query_map([], |row| {
848 Ok((
849 row.get::<_, String>(0)?,
850 row.get::<_, String>(1)?,
851 row.get::<_, String>(2)?,
852 row.get::<_, String>(3)?,
853 row.get::<_, Option<Vec<u8>>>(4)?,
854 row.get::<_, Option<String>>(5)?,
855 ))
856 })
857 .map_err(|error| SyncwebError::operation("failed to query networks", error))?
858 .collect::<std::result::Result<Vec<_>, _>>()
859 .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
860
861 let mut member_stmt = connection
862 .prepare("SELECT member FROM network_members WHERE network_id = ?1")
863 .map_err(|error| SyncwebError::operation("failed to prepare member query", error))?;
864 let mut folder_stmt = connection
865 .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
866 .map_err(|error| SyncwebError::operation("failed to prepare folder query", error))?;
867
868 let mut result = Vec::new();
869 for (id_str, name, label, owner_str, shared_secret_bytes, doc_ticket) in networks {
870 let network_id = NetworkId::from_str(&id_str)
871 .map_err(|error| SyncwebError::operation("invalid network ID in database", error))?;
872 let owner = parse_public_key(&owner_str)?;
873 let shared_secret = shared_secret_bytes
874 .map(|bytes| -> Result<[u8; 32]> {
875 bytes.try_into().map_err(|_bytes: Vec<u8>| {
876 SyncwebError::operation("invalid shared secret length in database", "expected 32 bytes")
877 })
878 })
879 .transpose()?;
880
881 let members: HashSet<PublicKey> = member_stmt
882 .query_map(params![id_str], |row| row.get::<_, String>(0))
883 .map_err(|error| SyncwebError::operation("failed to query members", error))?
884 .collect::<std::result::Result<Vec<_>, _>>()
885 .map_err(|error| SyncwebError::operation("failed to read member rows", error))?
886 .into_iter()
887 .map(|s| parse_public_key(&s))
888 .collect::<Result<HashSet<_>>>()?;
889
890 let folders: HashSet<NamespaceId> = folder_stmt
891 .query_map(params![id_str], |row| row.get::<_, String>(0))
892 .map_err(|error| SyncwebError::operation("failed to query folders", error))?
893 .collect::<std::result::Result<Vec<_>, _>>()
894 .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?
895 .into_iter()
896 .map(|s| s.parse())
897 .collect::<std::result::Result<HashSet<_>, _>>()
898 .map_err(|error| SyncwebError::operation("invalid folder namespace in database", error))?;
899
900 result.push(Network {
901 id: network_id,
902 name,
903 label,
904 topic: network_topic(network_id),
905 owner,
906 members,
907 folders,
908 shared_secret,
909 doc_ticket,
910 });
911 }
912 drop(stmt);
913 drop(member_stmt);
914 drop(folder_stmt);
915 drop(connection);
916 Ok(result)
917 }
918
919 pub fn get_collection_state(&self) -> Result<CollectionState> {
925 let connection = self
926 .connection
927 .lock()
928 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
929 let mut stmt = connection
930 .prepare("SELECT collection_id, manifest_hash, current_version FROM installed_collections")
931 .map_err(|error| SyncwebError::operation("failed to prepare collection query", error))?;
932 let collections: Vec<(String, String, String)> = stmt
933 .query_map([], |row| {
934 Ok((
935 row.get::<_, String>(0)?,
936 row.get::<_, String>(1)?,
937 row.get::<_, String>(2)?,
938 ))
939 })
940 .map_err(|error| SyncwebError::operation("failed to query collections", error))?
941 .collect::<std::result::Result<Vec<_>, _>>()
942 .map_err(|error| SyncwebError::operation("failed to read collection rows", error))?;
943
944 let mut version_stmt = connection
945 .prepare("SELECT version, install_path FROM collection_versions WHERE collection_id = ?1")
946 .map_err(|error| SyncwebError::operation("failed to prepare version query", error))?;
947
948 let mut installed = BTreeMap::new();
949 for (id_str, manifest_hash, current_version) in collections {
950 let collection_id = Uuid::from_str(&id_str)
951 .map_err(|error| SyncwebError::operation("invalid collection ID in database", error))?;
952 let manifest = iroh_blobs::Hash::from_str(&manifest_hash)
953 .map_err(|error| SyncwebError::operation("invalid manifest hash in database", error))?;
954
955 let versions: BTreeMap<String, PathBuf> = version_stmt
956 .query_map(params![id_str], |row| {
957 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
958 })
959 .map_err(|error| SyncwebError::operation("failed to query versions", error))?
960 .collect::<std::result::Result<Vec<_>, _>>()
961 .map_err(|error| SyncwebError::operation("failed to read version rows", error))?
962 .into_iter()
963 .map(|(v, p)| (v, PathBuf::from(p)))
964 .collect();
965
966 installed.insert(
967 collection_id,
968 InstalledCollection {
969 manifest,
970 versions,
971 current: current_version,
972 },
973 );
974 }
975 drop(stmt);
976 drop(version_stmt);
977 drop(connection);
978 Ok(CollectionState { installed })
979 }
980
981 pub fn install_collection(
987 &self,
988 collection_id: Uuid,
989 manifest_hash: &iroh_blobs::Hash,
990 version: &str,
991 path: &Path,
992 ) -> Result<()> {
993 let connection = self
994 .connection
995 .lock()
996 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
997 let now = current_timestamp().cast_signed();
998 connection
999 .execute(
1000 "INSERT INTO installed_collections(collection_id, manifest_hash, current_version, installed_at)
1001 VALUES (?1, ?2, ?3, ?4)
1002 ON CONFLICT(collection_id) DO UPDATE SET
1003 manifest_hash = excluded.manifest_hash,
1004 current_version = excluded.current_version,
1005 installed_at = excluded.installed_at",
1006 params![collection_id.to_string(), manifest_hash.to_string(), version, now],
1007 )
1008 .map_err(|error| SyncwebError::operation("failed to install collection", error))?;
1009 connection
1010 .execute(
1011 "INSERT OR IGNORE INTO collection_versions(collection_id, version, install_path) VALUES (?1, ?2, ?3)",
1012 params![collection_id.to_string(), version, path.to_string_lossy().as_ref()],
1013 )
1014 .map_err(|error| SyncwebError::operation("failed to record collection version", error))?;
1015 drop(connection);
1016 Ok(())
1017 }
1018
1019 pub fn switch_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
1025 let connection = self
1026 .connection
1027 .lock()
1028 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1029 connection
1030 .execute(
1031 "UPDATE installed_collections SET current_version = ?1 WHERE collection_id = ?2",
1032 params![version, collection_id.to_string()],
1033 )
1034 .map_err(|error| SyncwebError::operation("failed to switch collection version", error))?;
1035 drop(connection);
1036 Ok(())
1037 }
1038
1039 pub fn remove_collection_version(&self, collection_id: Uuid, version: &str) -> Result<()> {
1045 let connection = self
1046 .connection
1047 .lock()
1048 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1049 connection
1050 .execute(
1051 "DELETE FROM collection_versions WHERE collection_id = ?1 AND version = ?2",
1052 params![collection_id.to_string(), version],
1053 )
1054 .map_err(|error| SyncwebError::operation("failed to remove collection version", error))?;
1055
1056 let remaining: i64 = connection
1057 .query_row(
1058 "SELECT COUNT(*) FROM collection_versions WHERE collection_id = ?1",
1059 params![collection_id.to_string()],
1060 |row| row.get(0),
1061 )
1062 .map_err(|error| SyncwebError::operation("failed to check remaining versions", error))?;
1063 if remaining == 0 {
1064 connection
1065 .execute(
1066 "DELETE FROM installed_collections WHERE collection_id = ?1",
1067 params![collection_id.to_string()],
1068 )
1069 .map_err(|error| SyncwebError::operation("failed to remove installed collection", error))?;
1070 }
1071 drop(connection);
1072 Ok(())
1073 }
1074
1075 pub fn get_config(&self, key: &str) -> Result<Option<String>> {
1081 let connection = self
1082 .connection
1083 .lock()
1084 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1085 let result = connection
1086 .query_row("SELECT value FROM app_config WHERE key = ?1", params![key], |row| {
1087 row.get::<_, String>(0)
1088 })
1089 .optional()
1090 .map_err(|error| SyncwebError::operation("failed to get config", error))?;
1091 drop(connection);
1092 Ok(result)
1093 }
1094
1095 pub fn set_config(&self, key: &str, value: &str) -> Result<()> {
1101 let connection = self
1102 .connection
1103 .lock()
1104 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1105 let now = current_timestamp().cast_signed();
1106 connection
1107 .execute(
1108 "INSERT INTO app_config(key, value, updated_at) VALUES (?1, ?2, ?3)
1109 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
1110 params![key, value, now],
1111 )
1112 .map_err(|error| SyncwebError::operation("failed to set config", error))?;
1113 drop(connection);
1114 Ok(())
1115 }
1116
1117 pub fn load_app_config(&self) -> Result<AppConfig> {
1123 let connection = self
1124 .connection
1125 .lock()
1126 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1127 let blob: Option<String> = connection
1128 .query_row("SELECT value FROM app_config WHERE key = 'config_blob'", [], |row| {
1129 row.get(0)
1130 })
1131 .optional()
1132 .map_err(|error| SyncwebError::operation("failed to load app config", error))?;
1133 drop(connection);
1134 blob.map_or_else(
1135 || Ok(AppConfig::default()),
1136 |toml_str| {
1137 toml::from_str(&toml_str).map_err(|error| SyncwebError::operation("failed to parse app config", error))
1138 },
1139 )
1140 }
1141
1142 pub fn save_app_config(&self, config: &AppConfig) -> Result<()> {
1148 let now = current_timestamp().cast_signed();
1149 let blob = toml::to_string_pretty(config)
1150 .map_err(|error| SyncwebError::operation("failed to serialize app config", error))?;
1151 let connection = self
1152 .connection
1153 .lock()
1154 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1155 connection
1156 .execute(
1157 "INSERT INTO app_config(key, value, updated_at) VALUES ('config_blob', ?1, ?2)
1158 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
1159 params![blob, now],
1160 )
1161 .map_err(|error| SyncwebError::operation("failed to save app config", error))?;
1162 drop(connection);
1163 Ok(())
1164 }
1165
1166 pub fn load_filter_engine(&self) -> Result<Option<FilterEngine>> {
1172 let connection = self
1173 .connection
1174 .lock()
1175 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1176 let mut stmt = connection
1177 .prepare("SELECT namespace_id, rule_type, pattern FROM filter_rules ORDER BY priority, id")
1178 .map_err(|error| SyncwebError::operation("failed to prepare filter query", error))?;
1179 let rows: Vec<(Option<String>, String, String)> = stmt
1180 .query_map([], |row| {
1181 Ok((
1182 row.get::<_, Option<String>>(0)?,
1183 row.get::<_, String>(1)?,
1184 row.get::<_, String>(2)?,
1185 ))
1186 })
1187 .map_err(|error| SyncwebError::operation("failed to query filters", error))?
1188 .collect::<std::result::Result<Vec<_>, _>>()
1189 .map_err(|error| SyncwebError::operation("failed to read filter rows", error))?;
1190 drop(stmt);
1191 drop(connection);
1192
1193 if rows.is_empty() {
1194 return Ok(None);
1195 }
1196
1197 let mut global_rules = Vec::new();
1198 let mut folder_rules: HashMap<String, Vec<FilterRule>> = HashMap::new();
1199 for (namespace_id, rule_type, pattern) in rows {
1200 let action = match rule_type.as_str() {
1201 "include" => crate::filter::FilterAction::Accept,
1202 "exclude" => crate::filter::FilterAction::Reject,
1203 _ => continue,
1204 };
1205 let criteria = crate::filter::MatchCriteria {
1206 name: Some(pattern),
1207 ..Default::default()
1208 };
1209 let rule = FilterRule::new(action, criteria);
1210 match namespace_id {
1211 Some(ns) => folder_rules.entry(ns).or_default().push(rule),
1212 None => global_rules.push(rule),
1213 }
1214 }
1215
1216 let filter_config = crate::filter::FilterConfig {
1217 rules: global_rules,
1218 folders: folder_rules,
1219 default_action: FilterAction::default(),
1220 };
1221 FilterEngine::new(filter_config).map(Some)
1222 }
1223
1224 pub fn save_filter_rules(&self, rules: &[FilterEntry]) -> Result<()> {
1230 let connection = self
1231 .connection
1232 .lock()
1233 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1234 let now = current_timestamp().cast_signed();
1235 connection
1236 .execute("DELETE FROM filter_rules", [])
1237 .map_err(|error| SyncwebError::operation("failed to clear filter rules", error))?;
1238 for entry in rules {
1239 let rule_type = if entry.size > 0 { "exclude" } else { "include" };
1240 connection
1241 .execute(
1242 "INSERT INTO filter_rules(namespace_id, rule_type, pattern, priority, updated_at)
1243 VALUES (NULL, ?1, ?2, 0, ?3)",
1244 params![rule_type, entry.path.to_string_lossy().as_ref(), now],
1245 )
1246 .map_err(|error| SyncwebError::operation("failed to save filter rule", error))?;
1247 }
1248 drop(connection);
1249 Ok(())
1250 }
1251
1252 pub fn load_subscriptions(&self) -> Result<HashSet<iroh_blobs::Hash>> {
1258 let connection = self
1259 .connection
1260 .lock()
1261 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1262 let mut stmt = connection
1263 .prepare("SELECT hash FROM public_subscriptions")
1264 .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1265 let hashes: HashSet<iroh_blobs::Hash> = stmt
1266 .query_map([], |row| {
1267 let text: String = row.get(0)?;
1268 Ok(text)
1269 })
1270 .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1271 .collect::<std::result::Result<Vec<_>, _>>()
1272 .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1273 .into_iter()
1274 .filter_map(|h| h.parse::<iroh_blobs::Hash>().ok())
1275 .collect();
1276 drop(stmt);
1277 drop(connection);
1278 Ok(hashes)
1279 }
1280
1281 pub fn load_subscriptions_with_sizes(&self) -> Result<Vec<(iroh_blobs::Hash, u64)>> {
1287 let connection = self
1288 .connection
1289 .lock()
1290 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1291 let mut stmt = connection
1292 .prepare("SELECT hash, size FROM public_subscriptions")
1293 .map_err(|error| SyncwebError::operation("failed to prepare subscription query", error))?;
1294 let results: Vec<(iroh_blobs::Hash, u64)> = stmt
1295 .query_map([], |row| {
1296 let text: String = row.get(0)?;
1297 let size: i64 = row.get(1)?;
1298 Ok((text, size))
1299 })
1300 .map_err(|error| SyncwebError::operation("failed to query subscriptions", error))?
1301 .collect::<std::result::Result<Vec<_>, _>>()
1302 .map_err(|error| SyncwebError::operation("failed to read subscription rows", error))?
1303 .into_iter()
1304 .filter_map(|(h, size)| {
1305 let hash = h.parse::<iroh_blobs::Hash>().ok()?;
1306 Some((hash, size.cast_unsigned()))
1307 })
1308 .collect();
1309 drop(stmt);
1310 drop(connection);
1311 Ok(results)
1312 }
1313
1314 pub fn save_subscription(&self, hash: &iroh_blobs::Hash, size: u64) -> Result<()> {
1320 let connection = self
1321 .connection
1322 .lock()
1323 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1324 let now = current_timestamp().cast_signed();
1325 connection
1326 .execute(
1327 "INSERT OR IGNORE INTO public_subscriptions(hash, size, subscribed_at) VALUES (?1, ?2, ?3)",
1328 params![hash.to_string(), size.cast_signed(), now],
1329 )
1330 .map_err(|error| SyncwebError::operation("failed to save subscription", error))?;
1331 drop(connection);
1332 Ok(())
1333 }
1334
1335 pub fn remove_subscription(&self, hash: &iroh_blobs::Hash) -> Result<()> {
1341 let connection = self
1342 .connection
1343 .lock()
1344 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1345 connection
1346 .execute(
1347 "DELETE FROM public_subscriptions WHERE hash = ?1",
1348 params![hash.to_string()],
1349 )
1350 .map_err(|error| SyncwebError::operation("failed to remove subscription", error))?;
1351 drop(connection);
1352 Ok(())
1353 }
1354
1355 pub fn save_workspace_manifest(&self, source_path: &str, manifest_bytes: &[u8], collection_id: &str) -> Result<()> {
1365 let connection = self
1366 .connection
1367 .lock()
1368 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1369 let now = current_timestamp().cast_signed();
1370 connection
1371 .execute(
1372 "INSERT OR REPLACE INTO workspace_manifests(source_path, manifest_bytes, collection_id, updated_at)
1373 VALUES (?1, ?2, ?3, ?4)",
1374 params![source_path, manifest_bytes, collection_id, now],
1375 )
1376 .map_err(|error| SyncwebError::operation("failed to save workspace manifest", error))?;
1377 drop(connection);
1378 Ok(())
1379 }
1380
1381 pub fn load_workspace_manifest(&self, source_path: &str) -> Result<Option<Vec<u8>>> {
1387 let connection = self
1388 .connection
1389 .lock()
1390 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1391 let result: Option<Vec<u8>> = connection
1392 .query_row(
1393 "SELECT manifest_bytes FROM workspace_manifests WHERE source_path = ?1",
1394 params![source_path],
1395 |row| row.get(0),
1396 )
1397 .optional()
1398 .map_err(|error| SyncwebError::operation("failed to load workspace manifest", error))?;
1399 drop(connection);
1400 Ok(result)
1401 }
1402
1403 pub fn delete_workspace_manifest(&self, source_path: &str) -> Result<()> {
1409 let connection = self
1410 .connection
1411 .lock()
1412 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1413 connection
1414 .execute(
1415 "DELETE FROM workspace_manifests WHERE source_path = ?1",
1416 params![source_path],
1417 )
1418 .map_err(|error| SyncwebError::operation("failed to delete workspace manifest", error))?;
1419 drop(connection);
1420 Ok(())
1421 }
1422
1423 pub fn record_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> 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(
1440 "INSERT OR IGNORE INTO blob_folders(content_hash, namespace_id, entry_key, added_at)
1441 VALUES (?1, ?2, ?3, ?4)",
1442 params![content_hash.as_slice(), namespace_id, entry_key, now,],
1443 )
1444 .map_err(|error| SyncwebError::operation("failed to record blob folder association", error))?;
1445 drop(connection);
1446 Ok(())
1447 }
1448
1449 pub fn remove_blob_folder(&self, content_hash: &[u8; 32], namespace_id: &str, entry_key: &[u8]) -> Result<()> {
1455 let connection = self
1456 .connection
1457 .lock()
1458 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1459 connection
1460 .execute(
1461 "DELETE FROM blob_folders WHERE content_hash = ?1 AND namespace_id = ?2 AND entry_key = ?3",
1462 params![content_hash.as_slice(), namespace_id, entry_key],
1463 )
1464 .map_err(|error| SyncwebError::operation("failed to remove blob folder association", error))?;
1465 drop(connection);
1466 Ok(())
1467 }
1468
1469 pub fn can_access_blob(&self, hash: &Hash, member: &str) -> Result<bool> {
1476 self.can_access_blob_in_network(hash.as_bytes(), member)
1477 }
1478
1479 pub fn can_access_blob_in_network(&self, content_hash: &[u8; 32], member: &str) -> Result<bool> {
1485 let connection = self
1486 .connection
1487 .lock()
1488 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1489 let exists: bool = connection
1490 .query_row(
1491 "SELECT 1 FROM blob_folders bf
1492 JOIN network_folders nf ON bf.namespace_id = nf.namespace_id
1493 JOIN network_members nm ON nf.network_id = nm.network_id
1494 WHERE bf.content_hash = ?1 AND nm.member = ?2
1495 LIMIT 1",
1496 params![content_hash.as_slice(), member],
1497 |_| Ok(()),
1498 )
1499 .optional()
1500 .map_err(|error| SyncwebError::operation("failed to check blob access", error))?
1501 .is_some();
1502 drop(connection);
1503 Ok(exists)
1504 }
1505
1506 pub fn can_access_folder(&self, namespace_id: &str, member: &str) -> Result<bool> {
1512 let connection = self
1513 .connection
1514 .lock()
1515 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1516 let exists: bool = connection
1517 .query_row(
1518 "SELECT 1 FROM network_folders nf
1519 JOIN network_members nm ON nf.network_id = nm.network_id
1520 WHERE nf.namespace_id = ?1 AND nm.member = ?2
1521 LIMIT 1",
1522 params![namespace_id, member],
1523 |_| Ok(()),
1524 )
1525 .optional()
1526 .map_err(|error| SyncwebError::operation("failed to check folder access", error))?
1527 .is_some();
1528 drop(connection);
1529 Ok(exists)
1530 }
1531
1532 pub fn folders_for_network(&self, network_id: &str) -> Result<Vec<String>> {
1538 let connection = self
1539 .connection
1540 .lock()
1541 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1542 let mut stmt = connection
1543 .prepare("SELECT namespace_id FROM network_folders WHERE network_id = ?1")
1544 .map_err(|error| SyncwebError::operation("failed to prepare folders query", error))?;
1545 let folders: Vec<String> = stmt
1546 .query_map(params![network_id], |row| row.get(0))
1547 .map_err(|error| SyncwebError::operation("failed to query folders", error))?
1548 .collect::<std::result::Result<Vec<_>, _>>()
1549 .map_err(|error| SyncwebError::operation("failed to read folder rows", error))?;
1550 drop(stmt);
1551 drop(connection);
1552 Ok(folders)
1553 }
1554
1555 pub fn networks_for_folder(&self, namespace_id: &str) -> Result<Vec<String>> {
1561 let connection = self
1562 .connection
1563 .lock()
1564 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1565 let mut stmt = connection
1566 .prepare("SELECT network_id FROM network_folders WHERE namespace_id = ?1")
1567 .map_err(|error| SyncwebError::operation("failed to prepare networks query", error))?;
1568 let networks: Vec<String> = stmt
1569 .query_map(params![namespace_id], |row| row.get(0))
1570 .map_err(|error| SyncwebError::operation("failed to query networks", error))?
1571 .collect::<std::result::Result<Vec<_>, _>>()
1572 .map_err(|error| SyncwebError::operation("failed to read network rows", error))?;
1573 drop(stmt);
1574 drop(connection);
1575 Ok(networks)
1576 }
1577
1578 pub fn members_of_network(&self, network_id: &str) -> Result<Vec<String>> {
1584 let connection = self
1585 .connection
1586 .lock()
1587 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1588 let mut stmt = connection
1589 .prepare("SELECT member FROM network_members WHERE network_id = ?1")
1590 .map_err(|error| SyncwebError::operation("failed to prepare members query", error))?;
1591 let members: Vec<String> = stmt
1592 .query_map(params![network_id], |row| row.get(0))
1593 .map_err(|error| SyncwebError::operation("failed to query members", error))?
1594 .collect::<std::result::Result<Vec<_>, _>>()
1595 .map_err(|error| SyncwebError::operation("failed to read member rows", error))?;
1596 drop(stmt);
1597 drop(connection);
1598 Ok(members)
1599 }
1600
1601 pub fn is_peer_network_member(&self, peer_public_key: &str) -> Result<bool> {
1608 let connection = self
1609 .connection
1610 .lock()
1611 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1612 let exists: bool = connection
1613 .query_row(
1614 "SELECT 1 FROM network_members WHERE member = ?1 LIMIT 1",
1615 params![peer_public_key],
1616 |_| Ok(()),
1617 )
1618 .optional()
1619 .map_err(|error| SyncwebError::operation("failed to check peer membership", error))?
1620 .is_some();
1621 drop(connection);
1622 Ok(exists)
1623 }
1624
1625 pub fn upsert_storage_root(&self, root: &StorageRootRecord) -> Result<()> {
1635 let connection = self
1636 .connection
1637 .lock()
1638 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1639 let now = current_timestamp().cast_signed();
1640 connection
1641 .execute(
1642 "INSERT INTO storage_roots(id, path, min_free, enabled, created_at, updated_at)
1643 VALUES (?1, ?2, ?3, ?4, ?5, ?5)
1644 ON CONFLICT(id) DO UPDATE SET
1645 path = excluded.path,
1646 min_free = excluded.min_free,
1647 enabled = excluded.enabled,
1648 updated_at = excluded.updated_at",
1649 params![
1650 root.id,
1651 root.path.to_string_lossy().as_ref(),
1652 root.min_free.cast_signed(),
1653 i64::from(root.enabled),
1654 now,
1655 ],
1656 )
1657 .map_err(|error| SyncwebError::operation("failed to save storage root", error))?;
1658 drop(connection);
1659 Ok(())
1660 }
1661
1662 pub fn list_storage_roots(&self) -> Result<Vec<StorageRootRecord>> {
1668 let connection = self
1669 .connection
1670 .lock()
1671 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1672 let mut statement = connection
1673 .prepare("SELECT id, path, min_free, enabled FROM storage_roots ORDER BY id")
1674 .map_err(|error| SyncwebError::operation("failed to prepare storage root query", error))?;
1675 let roots = statement
1676 .query_map([], |row| {
1677 let min_free: i64 = row.get(2)?;
1678 let enabled: i64 = row.get(3)?;
1679 Ok(StorageRootRecord {
1680 id: row.get(0)?,
1681 path: PathBuf::from(row.get::<_, String>(1)?),
1682 min_free: min_free.cast_unsigned(),
1683 enabled: enabled != 0,
1684 })
1685 })
1686 .map_err(|error| SyncwebError::operation("failed to query storage roots", error))?
1687 .collect::<std::result::Result<Vec<_>, _>>()
1688 .map_err(|error| SyncwebError::operation("failed to read storage root rows", error))?;
1689 drop(statement);
1690 drop(connection);
1691 Ok(roots)
1692 }
1693
1694 pub fn remove_storage_root(&self, id: &str) -> Result<()> {
1700 let connection = self
1701 .connection
1702 .lock()
1703 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1704 connection
1705 .execute("DELETE FROM storage_roots WHERE id = ?1", params![id])
1706 .map_err(|error| SyncwebError::operation("failed to remove storage root", error))?;
1707 drop(connection);
1708 Ok(())
1709 }
1710
1711 pub fn enqueue_transfer_job(&self, job: &NewTransferJob<'_>) -> Result<String> {
1717 let id = Uuid::new_v4().to_string();
1718 let connection = self
1719 .connection
1720 .lock()
1721 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1722 let now = current_timestamp().cast_signed();
1723 connection
1724 .execute(
1725 "INSERT INTO transfer_jobs(
1726 id, namespace_id, entry_key, hash, size, root_id, destination,
1727 state, created_at, updated_at
1728 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'queued', ?8, ?8)",
1729 params![
1730 id,
1731 job.namespace_id,
1732 job.entry_key,
1733 job.hash.as_slice(),
1734 job.size.cast_signed(),
1735 job.root_id,
1736 job.destination.map(|path| path.to_string_lossy().into_owned()),
1737 now,
1738 ],
1739 )
1740 .map_err(|error| SyncwebError::operation("failed to enqueue transfer job", error))?;
1741 drop(connection);
1742 Ok(id)
1743 }
1744
1745 pub fn list_transfer_jobs(
1751 &self,
1752 namespace_id: Option<&str>,
1753 state: Option<&str>,
1754 ) -> Result<Vec<TransferJobRecord>> {
1755 let connection = self
1756 .connection
1757 .lock()
1758 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1759 let jobs = match (namespace_id, state) {
1760 (Some(namespace_filter), Some(state_filter)) => {
1761 let mut statement = connection
1762 .prepare(
1763 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1764 state, bytes_transferred, peer_count, eta_seconds, retries,
1765 error_message, created_at, updated_at
1766 FROM transfer_jobs
1767 WHERE namespace_id = ?1 AND state = ?2
1768 ORDER BY created_at, id",
1769 )
1770 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1771 statement
1772 .query_map(params![namespace_filter, state_filter], transfer_job_from_row)
1773 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1774 .collect::<std::result::Result<Vec<_>, _>>()
1775 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1776 }
1777 (Some(namespace_filter), None) => {
1778 let mut statement = connection
1779 .prepare(
1780 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1781 state, bytes_transferred, peer_count, eta_seconds, retries,
1782 error_message, created_at, updated_at
1783 FROM transfer_jobs
1784 WHERE namespace_id = ?1
1785 ORDER BY created_at, id",
1786 )
1787 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1788 statement
1789 .query_map(params![namespace_filter], transfer_job_from_row)
1790 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1791 .collect::<std::result::Result<Vec<_>, _>>()
1792 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1793 }
1794 (None, Some(state_filter)) => {
1795 let mut statement = connection
1796 .prepare(
1797 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1798 state, bytes_transferred, peer_count, eta_seconds, retries,
1799 error_message, created_at, updated_at
1800 FROM transfer_jobs
1801 WHERE state = ?1
1802 ORDER BY created_at, id",
1803 )
1804 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1805 statement
1806 .query_map(params![state_filter], transfer_job_from_row)
1807 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1808 .collect::<std::result::Result<Vec<_>, _>>()
1809 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1810 }
1811 (None, None) => {
1812 let mut statement = connection
1813 .prepare(
1814 "SELECT id, namespace_id, entry_key, hash, size, root_id, destination,
1815 state, bytes_transferred, peer_count, eta_seconds, retries,
1816 error_message, created_at, updated_at
1817 FROM transfer_jobs
1818 ORDER BY created_at, id",
1819 )
1820 .map_err(|error| SyncwebError::operation("failed to prepare transfer job query", error))?;
1821 statement
1822 .query_map([], transfer_job_from_row)
1823 .map_err(|error| SyncwebError::operation("failed to query transfer jobs", error))?
1824 .collect::<std::result::Result<Vec<_>, _>>()
1825 .map_err(|error| SyncwebError::operation("failed to read transfer job rows", error))?
1826 }
1827 };
1828 drop(connection);
1829 Ok(jobs)
1830 }
1831
1832 pub fn update_transfer_job_state(&self, id: &str, state: &str, error_message: Option<&str>) -> Result<()> {
1838 let connection = self
1839 .connection
1840 .lock()
1841 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1842 let now = current_timestamp().cast_signed();
1843 let updated = connection
1844 .execute(
1845 "UPDATE transfer_jobs
1846 SET state = ?1, error_message = ?2, updated_at = ?3
1847 WHERE id = ?4",
1848 params![state, error_message, now, id],
1849 )
1850 .map_err(|error| SyncwebError::operation("failed to update transfer job state", error))?;
1851 if updated == 0 {
1852 return Err(SyncwebError::InvalidConfig(format!("transfer job not found: {id}")));
1853 }
1854 drop(connection);
1855 Ok(())
1856 }
1857
1858 pub fn transition_transfer_job_state(
1864 &self,
1865 id: &str,
1866 expected_state: &str,
1867 state: &str,
1868 error_message: Option<&str>,
1869 ) -> Result<bool> {
1870 let connection = self
1871 .connection
1872 .lock()
1873 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1874 let now = current_timestamp().cast_signed();
1875 let updated = connection
1876 .execute(
1877 "UPDATE transfer_jobs
1878 SET state = ?1, error_message = ?2, updated_at = ?3
1879 WHERE id = ?4 AND state = ?5",
1880 params![state, error_message, now, id, expected_state],
1881 )
1882 .map_err(|error| SyncwebError::operation("failed to transition transfer job state", error))?;
1883 drop(connection);
1884 Ok(updated != 0)
1885 }
1886
1887 pub fn assign_transfer_job(&self, id: &str, root_id: &str, destination: &Path) -> Result<()> {
1893 let connection = self
1894 .connection
1895 .lock()
1896 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1897 let now = current_timestamp().cast_signed();
1898 let updated = connection
1899 .execute(
1900 "UPDATE transfer_jobs
1901 SET root_id = ?1, destination = ?2, updated_at = ?3
1902 WHERE id = ?4 AND state = 'queued'",
1903 params![root_id, destination.to_string_lossy().as_ref(), now, id],
1904 )
1905 .map_err(|error| SyncwebError::operation("failed to assign transfer job", error))?;
1906 if updated == 0 {
1907 return Err(SyncwebError::InvalidConfig(format!(
1908 "transfer job is missing or not queued: {id}"
1909 )));
1910 }
1911 drop(connection);
1912 Ok(())
1913 }
1914
1915 pub fn update_transfer_job_progress(
1921 &self,
1922 id: &str,
1923 bytes_transferred: u64,
1924 peer_count: u64,
1925 eta_seconds: Option<u64>,
1926 retries: u32,
1927 ) -> Result<()> {
1928 let connection = self
1929 .connection
1930 .lock()
1931 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1932 let now = current_timestamp().cast_signed();
1933 let updated = connection
1934 .execute(
1935 "UPDATE transfer_jobs
1936 SET bytes_transferred = ?1, peer_count = ?2, eta_seconds = ?3,
1937 retries = ?4, updated_at = ?5
1938 WHERE id = ?6",
1939 params![
1940 bytes_transferred.cast_signed(),
1941 peer_count.cast_signed(),
1942 eta_seconds.map(u64::cast_signed),
1943 retries.cast_signed(),
1944 now,
1945 id,
1946 ],
1947 )
1948 .map_err(|error| SyncwebError::operation("failed to update transfer job progress", error))?;
1949 if updated == 0 {
1950 return Err(SyncwebError::InvalidConfig(format!("transfer job not found: {id}")));
1951 }
1952 drop(connection);
1953 Ok(())
1954 }
1955
1956 pub fn retry_transfer_job(&self, id: &str) -> Result<()> {
1962 let connection = self
1963 .connection
1964 .lock()
1965 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1966 let now = current_timestamp().cast_signed();
1967 let updated = connection
1968 .execute(
1969 "UPDATE transfer_jobs
1970 SET state = 'queued', error_message = NULL,
1971 retries = retries + 1, updated_at = ?1
1972 WHERE id = ?2 AND state IN ('failed', 'paused', 'cancelled')",
1973 params![now, id],
1974 )
1975 .map_err(|error| SyncwebError::operation("failed to retry transfer job", error))?;
1976 if updated == 0 {
1977 return Err(SyncwebError::InvalidConfig(format!(
1978 "transfer job is missing or not retryable: {id}"
1979 )));
1980 }
1981 drop(connection);
1982 Ok(())
1983 }
1984
1985 pub fn recover_transfer_jobs(&self) -> Result<usize> {
1991 let connection = self
1992 .connection
1993 .lock()
1994 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
1995 let now = current_timestamp().cast_signed();
1996 let recovered = connection
1997 .execute(
1998 "UPDATE transfer_jobs
1999 SET state = 'queued', updated_at = ?1
2000 WHERE state IN ('fetching', 'materializing')",
2001 params![now],
2002 )
2003 .map_err(|error| SyncwebError::operation("failed to recover transfer jobs", error))?;
2004 drop(connection);
2005 Ok(recovered)
2006 }
2007
2008 pub fn reserved_transfer_bytes(&self, root_id: &str) -> Result<u64> {
2014 let connection = self
2015 .connection
2016 .lock()
2017 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2018 let reserved: i64 = connection
2019 .query_row(
2020 "SELECT COALESCE(SUM(size), 0)
2021 FROM transfer_jobs
2022 WHERE root_id = ?1
2023 AND state IN ('queued', 'fetching', 'materializing', 'paused')",
2024 params![root_id],
2025 |row| row.get(0),
2026 )
2027 .map_err(|error| SyncwebError::operation("failed to query reserved transfer bytes", error))?;
2028 drop(connection);
2029 Ok(reserved.cast_unsigned())
2030 }
2031
2032 pub fn create_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
2042 let connection = self
2043 .connection
2044 .lock()
2045 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2046 let now = current_timestamp().cast_signed();
2047 connection
2048 .execute(
2049 "INSERT INTO sync_checkpoints(namespace_id, session_id, started_at, last_updated_at, status)
2050 VALUES (?1, ?2, ?3, ?4, 'running')",
2051 params![namespace_id, session_id, now, now],
2052 )
2053 .map_err(|error| SyncwebError::operation("failed to create sync checkpoint", error))?;
2054 drop(connection);
2055 Ok(())
2056 }
2057
2058 pub fn upsert_sync_entry(&self, p: &SyncEntryParams<'_>) -> Result<()> {
2064 let connection = self
2065 .connection
2066 .lock()
2067 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2068 let now = current_timestamp().cast_signed();
2069 connection
2070 .execute(
2071 "INSERT INTO sync_entry_progress(namespace_id, session_id, entry_key, hash, size, status, retries, error_message, updated_at)
2072 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2073 ON CONFLICT(namespace_id, session_id, entry_key) DO UPDATE SET
2074 status = excluded.status,
2075 retries = excluded.retries,
2076 error_message = excluded.error_message,
2077 updated_at = excluded.updated_at",
2078 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],
2079 )
2080 .map_err(|error| SyncwebError::operation("failed to upsert sync entry", error))?;
2081 drop(connection);
2082 Ok(())
2083 }
2084
2085 pub fn list_sync_entries(
2091 &self,
2092 namespace_id: &str,
2093 session_id: &str,
2094 status: &str,
2095 ) -> Result<Vec<crate::sync::checkpoint::EntryProgress>> {
2096 let connection = self
2097 .connection
2098 .lock()
2099 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2100 let mut stmt = connection
2101 .prepare(
2102 "SELECT entry_key, hash, size, status, retries, error_message
2103 FROM sync_entry_progress
2104 WHERE namespace_id = ?1 AND session_id = ?2 AND status = ?3",
2105 )
2106 .map_err(|error| SyncwebError::operation("failed to prepare sync entry query", error))?;
2107 let entries = stmt
2108 .query_map(params![namespace_id, session_id, status], |row| {
2109 let entry_key: Vec<u8> = row.get(0)?;
2110 let hash_bytes: Vec<u8> = row.get(1)?;
2111 let hash_str = String::from_utf8_lossy(&hash_bytes);
2112 let hash = hash_str.parse::<iroh_blobs::Hash>().unwrap_or(iroh_blobs::Hash::EMPTY);
2113 let size: i64 = row.get(2)?;
2114 let status_str: String = row.get(3)?;
2115 let retries: i64 = row.get(4)?;
2116 let error_message: Option<String> = row.get(5)?;
2117 Ok(crate::sync::checkpoint::EntryProgress {
2118 entry_key,
2119 hash,
2120 size: size.cast_unsigned(),
2121 status: status_str,
2122 retries: u32::try_from(retries).unwrap_or(0),
2123 error_message,
2124 })
2125 })
2126 .map_err(|error| SyncwebError::operation("failed to query sync entries", error))?
2127 .collect::<std::result::Result<Vec<_>, _>>()
2128 .map_err(|error| SyncwebError::operation("failed to read sync entry rows", error))?;
2129 drop(stmt);
2130 drop(connection);
2131 Ok(entries)
2132 }
2133
2134 pub fn get_checkpoint_progress(
2140 &self,
2141 namespace_id: &str,
2142 session_id: &str,
2143 ) -> Result<crate::sync::checkpoint::CheckpointProgress> {
2144 let connection = self
2145 .connection
2146 .lock()
2147 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2148 let (total, completed, failed, skipped, pending, bytes_transferred, bytes_total): (
2149 i64, i64, i64, i64, i64, i64, Option<i64>,
2150 ) = connection
2151 .query_row(
2152 "SELECT
2153 (SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2),
2154 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
2155 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'failed'), 0),
2156 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'skipped'), 0),
2157 COALESCE((SELECT COUNT(*) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'pending'), 0),
2158 COALESCE((SELECT SUM(size) FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2 AND status = 'completed'), 0),
2159 (SELECT bytes_total FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2)",
2160 params![namespace_id, session_id],
2161 |row| Ok((
2162 row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?
2163 )),
2164 )
2165 .map_err(|error| SyncwebError::operation("failed to query checkpoint progress", error))?;
2166 drop(connection);
2167 let percentage = if total > 0 {
2168 let sum = completed.checked_add(skipped).unwrap_or(0);
2169 let pct_int = sum.checked_mul(100).and_then(|n| n.checked_div(total)).unwrap_or(0);
2170 f64::from(i32::try_from(pct_int).unwrap_or(0))
2171 } else {
2172 0.0
2173 };
2174 Ok(crate::sync::checkpoint::CheckpointProgress {
2175 total: usize::try_from(total).unwrap_or(0),
2176 completed: usize::try_from(completed).unwrap_or(0),
2177 failed: usize::try_from(failed).unwrap_or(0),
2178 skipped: usize::try_from(skipped).unwrap_or(0),
2179 pending: usize::try_from(pending).unwrap_or(0),
2180 bytes_transferred: bytes_transferred.cast_unsigned(),
2181 bytes_total: bytes_total.map(i64::cast_unsigned),
2182 percentage,
2183 })
2184 }
2185
2186 pub fn update_checkpoint_status(&self, namespace_id: &str, session_id: &str, status: &str) -> Result<()> {
2192 let connection = self
2193 .connection
2194 .lock()
2195 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2196 let now = current_timestamp().cast_signed();
2197 connection
2198 .execute(
2199 "UPDATE sync_checkpoints SET status = ?1, last_updated_at = ?2
2200 WHERE namespace_id = ?3 AND session_id = ?4",
2201 params![status, now, namespace_id, session_id],
2202 )
2203 .map_err(|error| SyncwebError::operation("failed to update checkpoint status", error))?;
2204 drop(connection);
2205 Ok(())
2206 }
2207
2208 pub fn find_unfinished_checkpoint(&self, namespace_id: &str) -> Result<Option<String>> {
2214 let connection = self
2215 .connection
2216 .lock()
2217 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2218 let result: Option<String> = connection
2219 .query_row(
2220 "SELECT session_id FROM sync_checkpoints
2221 WHERE namespace_id = ?1 AND status IN ('running', 'pending')
2222 ORDER BY started_at DESC LIMIT 1",
2223 params![namespace_id],
2224 |row| row.get(0),
2225 )
2226 .optional()
2227 .map_err(|error| SyncwebError::operation("failed to find unfinished checkpoint", error))?;
2228 drop(connection);
2229 Ok(result)
2230 }
2231
2232 pub fn delete_sync_checkpoint(&self, namespace_id: &str, session_id: &str) -> Result<()> {
2238 let connection = self
2239 .connection
2240 .lock()
2241 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2242 connection
2243 .execute(
2244 "DELETE FROM sync_entry_progress WHERE namespace_id = ?1 AND session_id = ?2",
2245 params![namespace_id, session_id],
2246 )
2247 .map_err(|error| SyncwebError::operation("failed to delete sync entry progress", error))?;
2248 connection
2249 .execute(
2250 "DELETE FROM sync_checkpoints WHERE namespace_id = ?1 AND session_id = ?2",
2251 params![namespace_id, session_id],
2252 )
2253 .map_err(|error| SyncwebError::operation("failed to delete sync checkpoint", error))?;
2254 drop(connection);
2255 Ok(())
2256 }
2257
2258 pub fn clean_stale_checkpoints(&self, max_age_secs: i64) -> Result<usize> {
2264 let connection = self
2265 .connection
2266 .lock()
2267 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2268 let cutoff = current_timestamp().cast_signed().saturating_sub(max_age_secs);
2269 let deleted = connection
2270 .execute(
2271 "DELETE FROM sync_checkpoints WHERE last_updated_at < ?1 AND status NOT IN ('completed')",
2272 params![cutoff],
2273 )
2274 .map_err(|error| SyncwebError::operation("failed to clean stale checkpoints", error))?;
2275 drop(connection);
2276 Ok(deleted)
2277 }
2278
2279 pub fn vacuum(&self) -> Result<()> {
2289 let connection = self
2290 .connection
2291 .lock()
2292 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2293 connection
2294 .execute_batch("VACUUM")
2295 .map_err(|error| SyncwebError::operation("vacuum failed", error))?;
2296 drop(connection);
2297 Ok(())
2298 }
2299
2300 pub fn size_on_disk(&self) -> Result<u64> {
2306 let connection = self
2307 .connection
2308 .lock()
2309 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2310 let db_path: String = connection
2311 .query_row("PRAGMA database_list", [], |row| row.get(2))
2312 .map_err(|error| SyncwebError::operation("failed to query database path", error))?;
2313 drop(connection);
2314 std::fs::metadata(&db_path)
2315 .map(|meta| meta.len())
2316 .map_err(|error| SyncwebError::operation("failed to get database file size", error))
2317 }
2318
2319 pub fn freelist_count(&self) -> Result<i64> {
2325 let connection = self
2326 .connection
2327 .lock()
2328 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2329 let count: i64 = connection
2330 .pragma_query_value(None, "freelist_count", |row| row.get(0))
2331 .map_err(|error| SyncwebError::operation("failed to query freelist count", error))?;
2332 drop(connection);
2333 Ok(count)
2334 }
2335
2336 pub fn check_integrity(&self) -> Result<Vec<String>> {
2342 let connection = self
2343 .connection
2344 .lock()
2345 .map_err(|error| SyncwebError::operation("node database mutex is poisoned", error))?;
2346 let mut stmt = connection
2347 .prepare("PRAGMA integrity_check")
2348 .map_err(|error| SyncwebError::operation("failed to prepare integrity check", error))?;
2349 let errors: Vec<String> = stmt
2350 .query_map([], |row| row.get::<_, String>(0))
2351 .map_err(|error| SyncwebError::operation("failed to run integrity check", error))?
2352 .filter_map(std::result::Result::ok)
2353 .filter(|s| s != "ok")
2354 .collect();
2355 drop(stmt);
2356 drop(connection);
2357 Ok(errors)
2358 }
2359}
2360
2361impl crate::storage::Vacuumable for NodeDatabase {
2362 fn vacuum(&self) -> Result<()> {
2363 self.vacuum()
2364 }
2365
2366 fn freelist_count(&self) -> Result<i64> {
2367 self.freelist_count()
2368 }
2369}
2370
2371#[cfg(test)]
2372mod tests {
2373 use super::*;
2374 use std::sync::Arc;
2375
2376 fn test_db() -> NodeDatabase {
2377 let conn = Connection::open_in_memory().unwrap();
2378 let db = NodeDatabase {
2379 connection: Arc::new(Mutex::new(conn)),
2380 };
2381 db.init_schema().unwrap();
2382 db
2383 }
2384
2385 fn make_public_key(seed: u8) -> PublicKey {
2386 iroh::SecretKey::from_bytes(&[seed; 32]).public()
2387 }
2388
2389 #[test]
2390 fn test_is_peer_network_member_true() {
2391 let db = test_db();
2392 let member_key = make_public_key(1);
2393 let owner = make_public_key(2);
2394
2395 let mut network = Network::new("test", owner, crate::net::network::NetworkOptions::default());
2396 network.members.insert(member_key);
2397 db.create_network(&network).unwrap();
2398
2399 assert!(db.is_peer_network_member(&member_key.to_string()).unwrap());
2400 }
2401
2402 #[test]
2403 fn test_is_peer_network_member_false() {
2404 let db = test_db();
2405 assert!(!db.is_peer_network_member("unknown-peer").unwrap());
2406 }
2407
2408 #[test]
2409 fn test_transfer_roots_and_jobs_survive_lifecycle_updates() {
2410 let db = test_db();
2411 db.upsert_storage_root(&StorageRootRecord {
2412 id: "root".to_owned(),
2413 path: PathBuf::from("/tmp/syncweb-root"),
2414 min_free: 10,
2415 enabled: true,
2416 })
2417 .unwrap();
2418 let hash = [7_u8; 32];
2419 let entry_key = b"dir/file.bin";
2420 let job_id = db
2421 .enqueue_transfer_job(&NewTransferJob {
2422 namespace_id: "namespace",
2423 entry_key,
2424 hash: &hash,
2425 size: 25,
2426 root_id: Some("root"),
2427 destination: Some(Path::new("/tmp/syncweb-root/namespace/dir/file.bin")),
2428 })
2429 .unwrap();
2430
2431 assert_eq!(db.reserved_transfer_bytes("root").unwrap(), 25);
2432 let jobs = db.list_transfer_jobs(Some("namespace"), Some("queued")).unwrap();
2433 assert_eq!(jobs.len(), 1);
2434 let job = jobs.first().expect("queued transfer job should be listed");
2435 assert_eq!(job.id, job_id);
2436 assert_eq!(job.hash, hash);
2437 assert_eq!(
2438 job.destination.as_deref(),
2439 Some(Path::new("/tmp/syncweb-root/namespace/dir/file.bin"))
2440 );
2441
2442 db.update_transfer_job_progress(&job_id, 12, 2, Some(4), 1).unwrap();
2443 db.update_transfer_job_state(&job_id, "materializing", None).unwrap();
2444 assert_eq!(db.recover_transfer_jobs().unwrap(), 1);
2445 let recovered = db.list_transfer_jobs(None, Some("queued")).unwrap();
2446 let recovered_job = recovered.first().expect("recovered transfer job should be listed");
2447 assert_eq!(recovered_job.bytes_transferred, 12);
2448 assert_eq!(recovered_job.retries, 1);
2449 }
2450
2451 #[test]
2452 fn test_assign_transfer_job_requires_queued_job() {
2453 let db = test_db();
2454 db.upsert_storage_root(&StorageRootRecord {
2455 id: "root".to_owned(),
2456 path: PathBuf::from("/tmp/syncweb-root"),
2457 min_free: 0,
2458 enabled: true,
2459 })
2460 .unwrap();
2461 let hash = [3_u8; 32];
2462 let job_id = db
2463 .enqueue_transfer_job(&NewTransferJob {
2464 namespace_id: "namespace",
2465 entry_key: b"file",
2466 hash: &hash,
2467 size: 1,
2468 root_id: None,
2469 destination: None,
2470 })
2471 .unwrap();
2472 db.assign_transfer_job(&job_id, "root", Path::new("/tmp/file")).unwrap();
2473 let jobs = db.list_transfer_jobs(None, None).unwrap();
2474 let job = jobs.first().expect("assigned transfer job should be listed");
2475 assert_eq!(job.root_id.as_deref(), Some("root"));
2476 }
2477}